From 0b2be3f2aeb158e3f9ca6037acc23a664d064774 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 28 May 2026 18:36:23 -0700 Subject: [PATCH] Remove Google Discovery plugin --- apps/cloud/executor.config.ts | 2 +- apps/desktop/package.json | 1 - apps/local/executor.config.ts | 2 - apps/local/package.json | 1 - apps/local/src/server/executor.ts | 8 + ...google-discovery-openapi-migration.test.ts | 311 +++++++ .../google-discovery-openapi-migration.ts | 581 ++++++++++++ apps/marketing/package.json | 5 - apps/marketing/src/pages/api/detect.ts | 156 ---- bun.lock | 45 - examples/all-plugins/package.json | 1 - examples/all-plugins/src/main.ts | 9 +- packages/core/api/src/handlers/oauth.ts | 4 +- packages/core/api/src/oauth/api.ts | 4 +- packages/core/sdk/README.md | 3 +- packages/core/sdk/src/client.ts | 4 +- packages/core/sdk/src/core-tools.ts | 4 +- packages/core/sdk/src/executor.ts | 1 - packages/core/sdk/src/oauth-helpers.test.ts | 2 +- packages/core/sdk/src/oauth.ts | 3 +- .../plugins/google-discovery/CHANGELOG.md | 4 - packages/plugins/google-discovery/README.md | 79 -- .../google-discovery/fixtures/drive.json | 92 -- .../plugins/google-discovery/package.json | 97 -- .../plugins/google-discovery/src/api/group.ts | 134 --- .../google-discovery/src/api/handlers.test.ts | 150 ---- .../google-discovery/src/api/handlers.ts | 100 --- .../plugins/google-discovery/src/api/index.ts | 23 - .../plugins/google-discovery/src/promise.ts | 7 - .../src/react/AddGoogleDiscoverySource.tsx | 623 ------------- .../src/react/EditGoogleDiscoverySource.tsx | 82 -- .../src/react/GoogleDiscoverySignInButton.tsx | 99 --- .../react/GoogleDiscoverySourceSummary.tsx | 12 - .../google-discovery/src/react/atoms.ts | 63 -- .../google-discovery/src/react/client.ts | 7 - .../google-discovery/src/react/index.ts | 3 - .../google-discovery/src/react/oauth.ts | 27 - .../src/react/plugin-client.tsx | 8 - .../src/react/source-plugin.ts | 21 - .../google-discovery/src/sdk/binding-store.ts | 693 --------------- .../google-discovery/src/sdk/document.test.ts | 45 - .../google-discovery/src/sdk/document.ts | 530 ----------- .../google-discovery/src/sdk/errors.ts | 45 - .../plugins/google-discovery/src/sdk/index.ts | 36 - .../google-discovery/src/sdk/invoke.ts | 253 ------ .../src/sdk/option-json.test.ts | 109 --- .../google-discovery/src/sdk/plugin.test.ts | 676 -------------- .../google-discovery/src/sdk/plugin.ts | 825 ------------------ .../google-discovery/src/sdk/stored-source.ts | 17 - .../plugins/google-discovery/src/sdk/types.ts | 120 --- .../plugins/google-discovery/tsconfig.json | 23 - .../plugins/google-discovery/tsup.config.ts | 14 - .../plugins/google-discovery/vitest.config.ts | 8 - packages/plugins/openapi/src/api/group.ts | 1 + .../openapi/src/react/AddOpenApiSource.tsx | 13 +- .../plugins/openapi/src/sdk/definitions.ts | 25 + packages/plugins/openapi/src/sdk/extract.ts | 6 + .../openapi/src/sdk/google-discovery.test.ts | 104 +++ .../openapi/src/sdk/google-discovery.ts | 431 +++++++++ .../src/sdk/google-presets.ts} | 15 +- .../plugins/openapi/src/sdk/index.test.ts | 33 + packages/plugins/openapi/src/sdk/index.ts | 6 + packages/plugins/openapi/src/sdk/invoke.ts | 21 +- packages/plugins/openapi/src/sdk/plugin.ts | 78 +- packages/plugins/openapi/src/sdk/presets.ts | 11 +- .../src/sdk/query-serialization.test.ts | 97 ++ packages/plugins/openapi/src/sdk/types.ts | 1 + .../src/components/source-favicon.test.tsx | 8 +- .../react/src/components/source-favicon.tsx | 2 +- packages/react/src/pages/sources.tsx | 2 +- tests/presets-reachable.test.ts | 56 +- 71 files changed, 1754 insertions(+), 5328 deletions(-) create mode 100644 apps/local/src/server/google-discovery-openapi-migration.test.ts create mode 100644 apps/local/src/server/google-discovery-openapi-migration.ts delete mode 100644 apps/marketing/src/pages/api/detect.ts delete mode 100644 packages/plugins/google-discovery/CHANGELOG.md delete mode 100644 packages/plugins/google-discovery/README.md delete mode 100644 packages/plugins/google-discovery/fixtures/drive.json delete mode 100644 packages/plugins/google-discovery/package.json delete mode 100644 packages/plugins/google-discovery/src/api/group.ts delete mode 100644 packages/plugins/google-discovery/src/api/handlers.test.ts delete mode 100644 packages/plugins/google-discovery/src/api/handlers.ts delete mode 100644 packages/plugins/google-discovery/src/api/index.ts delete mode 100644 packages/plugins/google-discovery/src/promise.ts delete mode 100644 packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx delete mode 100644 packages/plugins/google-discovery/src/react/EditGoogleDiscoverySource.tsx delete mode 100644 packages/plugins/google-discovery/src/react/GoogleDiscoverySignInButton.tsx delete mode 100644 packages/plugins/google-discovery/src/react/GoogleDiscoverySourceSummary.tsx delete mode 100644 packages/plugins/google-discovery/src/react/atoms.ts delete mode 100644 packages/plugins/google-discovery/src/react/client.ts delete mode 100644 packages/plugins/google-discovery/src/react/index.ts delete mode 100644 packages/plugins/google-discovery/src/react/oauth.ts delete mode 100644 packages/plugins/google-discovery/src/react/plugin-client.tsx delete mode 100644 packages/plugins/google-discovery/src/react/source-plugin.ts delete mode 100644 packages/plugins/google-discovery/src/sdk/binding-store.ts delete mode 100644 packages/plugins/google-discovery/src/sdk/document.test.ts delete mode 100644 packages/plugins/google-discovery/src/sdk/document.ts delete mode 100644 packages/plugins/google-discovery/src/sdk/errors.ts delete mode 100644 packages/plugins/google-discovery/src/sdk/index.ts delete mode 100644 packages/plugins/google-discovery/src/sdk/invoke.ts delete mode 100644 packages/plugins/google-discovery/src/sdk/option-json.test.ts delete mode 100644 packages/plugins/google-discovery/src/sdk/plugin.test.ts delete mode 100644 packages/plugins/google-discovery/src/sdk/plugin.ts delete mode 100644 packages/plugins/google-discovery/src/sdk/stored-source.ts delete mode 100644 packages/plugins/google-discovery/src/sdk/types.ts delete mode 100644 packages/plugins/google-discovery/tsconfig.json delete mode 100644 packages/plugins/google-discovery/tsup.config.ts delete mode 100644 packages/plugins/google-discovery/vitest.config.ts create mode 100644 packages/plugins/openapi/src/sdk/google-discovery.test.ts create mode 100644 packages/plugins/openapi/src/sdk/google-discovery.ts rename packages/plugins/{google-discovery/src/sdk/presets.ts => openapi/src/sdk/google-presets.ts} (89%) create mode 100644 packages/plugins/openapi/src/sdk/query-serialization.test.ts diff --git a/apps/cloud/executor.config.ts b/apps/cloud/executor.config.ts index 21d903cba..7e38211c7 100644 --- a/apps/cloud/executor.config.ts +++ b/apps/cloud/executor.config.ts @@ -18,7 +18,7 @@ import { workosVaultPlugin, type WorkOSVaultClient } from "@executor-js/plugin-w // it has; all fields are optional so `plugins({})` keeps working. // // Cloud only ships plugins safe to run in a multi-tenant setting — no -// stdio MCP, no keychain/file-secrets/1password/google-discovery. +// stdio MCP, no keychain/file-secrets/1password. // --------------------------------------------------------------------------- interface CloudPluginDeps { diff --git a/apps/desktop/package.json b/apps/desktop/package.json index b6a225647..edb859040 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -31,7 +31,6 @@ "@executor-js/local": "workspace:*", "@executor-js/plugin-desktop-settings": "workspace:*", "@executor-js/plugin-file-secrets": "workspace:*", - "@executor-js/plugin-google-discovery": "workspace:*", "@executor-js/plugin-graphql": "workspace:*", "@executor-js/plugin-keychain": "workspace:*", "@executor-js/plugin-mcp": "workspace:*", diff --git a/apps/local/executor.config.ts b/apps/local/executor.config.ts index 2ab9827f4..e9a70b548 100644 --- a/apps/local/executor.config.ts +++ b/apps/local/executor.config.ts @@ -1,7 +1,6 @@ import { defineExecutorConfig } from "@executor-js/sdk"; import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; -import { googleDiscoveryHttpPlugin } from "@executor-js/plugin-google-discovery/api"; import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; import { keychainPlugin } from "@executor-js/plugin-keychain"; import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets"; @@ -22,7 +21,6 @@ export default defineExecutorConfig({ [ openApiHttpPlugin(), mcpHttpPlugin({ dangerouslyAllowStdioMCP: true }), - googleDiscoveryHttpPlugin(), graphqlHttpPlugin(), keychainPlugin(), fileSecretsPlugin(), diff --git a/apps/local/package.json b/apps/local/package.json index a7ba87765..72e79a4e5 100644 --- a/apps/local/package.json +++ b/apps/local/package.json @@ -30,7 +30,6 @@ "@executor-js/plugin-desktop-settings": "workspace:*", "@executor-js/plugin-example": "workspace:*", "@executor-js/plugin-file-secrets": "workspace:*", - "@executor-js/plugin-google-discovery": "workspace:*", "@executor-js/plugin-graphql": "workspace:*", "@executor-js/plugin-keychain": "workspace:*", "@executor-js/plugin-mcp": "workspace:*", diff --git a/apps/local/src/server/executor.ts b/apps/local/src/server/executor.ts index 10243ef1d..c99da6b4c 100644 --- a/apps/local/src/server/executor.ts +++ b/apps/local/src/server/executor.ts @@ -34,6 +34,7 @@ import { type LocalSqliteImportResult, } from "./sqlite-import"; import { createSqliteFumaDb } from "./sqlite-fumadb"; +import { oneShotMigrateGoogleDiscoveryToOpenApi } from "./google-discovery-openapi-migration"; interface ResolvedStorage { readonly dataDir: string; @@ -661,12 +662,19 @@ const createLocalExecutorLayer = () => { (db) => Effect.promise(() => db.close()).pipe(Effect.ignore), ); + const migratedGoogleDiscoverySources = oneShotMigrateGoogleDiscoveryToOpenApi(sqlite.sqlite); + if (importResult.imported) { console.warn( `[executor] Imported ${importResult.importedRows} row(s) into FumaDB SQLite storage` + (importResult.backupPath ? `; moved old DB to ${importResult.backupPath}.` : "."), ); } + if (migratedGoogleDiscoverySources > 0) { + console.warn( + `[executor] Migrated ${migratedGoogleDiscoverySources} Google Discovery source(s) to OpenAPI storage.`, + ); + } const scope = Scope.make({ id: ScopeId.make(scopeId), diff --git a/apps/local/src/server/google-discovery-openapi-migration.test.ts b/apps/local/src/server/google-discovery-openapi-migration.test.ts new file mode 100644 index 000000000..c6c547fd0 --- /dev/null +++ b/apps/local/src/server/google-discovery-openapi-migration.test.ts @@ -0,0 +1,311 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Database } from "bun:sqlite"; +import { Schema } from "effect"; + +import { oneShotMigrateGoogleDiscoveryToOpenApi } from "./google-discovery-openapi-migration"; + +const encodeJson = (value: unknown): Uint8Array => new TextEncoder().encode(JSON.stringify(value)); +const MigratedSourceData = Schema.Struct({ + config: Schema.Struct({ + spec: Schema.String, + oauth2: Schema.optional(Schema.Struct({ connectionSlot: Schema.optional(Schema.String) })), + }), +}); +const MigratedOperation = Schema.Struct({ + operationId: Schema.optional(Schema.String), + "x-executor-toolPath": Schema.optional(Schema.String), + parameters: Schema.optional(Schema.Array(Schema.Unknown)), +}); +const MigratedSpec = Schema.Struct({ + paths: Schema.Record(Schema.String, Schema.Record(Schema.String, MigratedOperation)), +}); +const decodeMigratedSourceData = Schema.decodeUnknownSync( + Schema.fromJsonString(MigratedSourceData), +); +const decodeMigratedSpec = Schema.decodeUnknownSync(Schema.fromJsonString(MigratedSpec)); + +const createMigrationFixture = () => { + const db = new Database(":memory:"); + db.exec(` + CREATE TABLE google_discovery_source ( + id text NOT NULL, + scope_id text NOT NULL, + name text NOT NULL, + config text NOT NULL, + auth_kind text NOT NULL, + auth_connection_id text, + auth_client_id_secret_id text, + auth_client_secret_secret_id text, + auth_scopes text, + created_at integer NOT NULL, + updated_at integer NOT NULL + ); + CREATE TABLE google_discovery_binding ( + id text NOT NULL, + scope_id text NOT NULL, + source_id text NOT NULL, + binding text NOT NULL, + created_at integer NOT NULL + ); + CREATE TABLE google_discovery_source_credential_header ( + id text NOT NULL, + scope_id text NOT NULL, + source_id text NOT NULL, + name text NOT NULL, + kind text NOT NULL, + text_value text, + secret_id text, + secret_prefix text + ); + CREATE TABLE google_discovery_source_credential_query_param ( + id text NOT NULL, + scope_id text NOT NULL, + source_id text NOT NULL, + name text NOT NULL, + kind text NOT NULL, + text_value text, + secret_id text, + secret_prefix text + ); + CREATE TABLE source ( + id text NOT NULL, + scope_id text NOT NULL, + plugin_id text NOT NULL, + kind text NOT NULL, + name text NOT NULL, + url text, + can_remove integer NOT NULL, + can_refresh integer NOT NULL, + can_edit integer NOT NULL, + created_at integer NOT NULL, + updated_at integer NOT NULL + ); + CREATE TABLE tool ( + id text NOT NULL, + scope_id text NOT NULL, + source_id text NOT NULL, + plugin_id text NOT NULL, + name text NOT NULL, + description text NOT NULL, + input_schema text, + output_schema text, + created_at integer NOT NULL, + updated_at integer NOT NULL + ); + CREATE TABLE definition ( + id text NOT NULL, + scope_id text NOT NULL, + source_id text NOT NULL, + plugin_id text NOT NULL, + name text NOT NULL, + schema text NOT NULL, + created_at integer NOT NULL + ); + CREATE TABLE plugin_storage ( + plugin_id text NOT NULL, + collection text NOT NULL, + key text NOT NULL, + data text NOT NULL, + created_at integer NOT NULL, + updated_at integer NOT NULL, + row_id text NOT NULL, + id text NOT NULL, + scope_id text NOT NULL + ); + CREATE TABLE credential_binding ( + plugin_id text NOT NULL, + source_id text NOT NULL, + source_scope_id text NOT NULL, + slot_key text NOT NULL, + kind text NOT NULL, + text_value text, + secret_id text, + secret_scope_id text, + connection_id text, + created_at integer NOT NULL, + updated_at integer NOT NULL, + row_id text NOT NULL, + id text NOT NULL, + scope_id text NOT NULL + ); + `); + return db; +}; + +describe("oneShotMigrateGoogleDiscoveryToOpenApi", () => { + it("moves a Google Discovery source into OpenAPI storage without changing tool ids", () => { + const db = createMigrationFixture(); + const now = 1_700_000_000; + const sourceId = "gmail_api"; + const scopeId = "local-scope"; + const toolId = `${sourceId}.users.messages.list`; + + db.prepare( + "INSERT INTO google_discovery_source (id, scope_id, name, config, auth_kind, auth_connection_id, auth_client_id_secret_id, auth_client_secret_secret_id, auth_scopes, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ).run( + sourceId, + scopeId, + "Gmail API", + encodeJson({ + discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest", + service: "gmail", + version: "v1", + rootUrl: "https://gmail.googleapis.com/", + servicePath: "", + }), + "oauth2", + "google-discovery-oauth2-gmail_api", + "client-id-secret", + "client-secret-secret", + encodeJson(["https://www.googleapis.com/auth/gmail.metadata"]), + now, + now, + ); + db.prepare( + "INSERT INTO source (id, scope_id, plugin_id, kind, name, url, can_remove, can_refresh, can_edit, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ).run( + sourceId, + scopeId, + "googleDiscovery", + "googleDiscovery", + "Gmail API", + null, + 1, + 0, + 1, + now, + now, + ); + db.prepare( + "INSERT INTO google_discovery_binding (id, scope_id, source_id, binding, created_at) VALUES (?, ?, ?, ?, ?)", + ).run( + toolId, + scopeId, + sourceId, + encodeJson({ + method: "get", + pathTemplate: "gmail/v1/users/{userId}/messages", + hasBody: false, + parameters: [ + { + name: "userId", + location: "path", + required: true, + repeated: false, + schema: { type: "string" }, + }, + { + name: "metadataHeaders", + location: "query", + required: false, + repeated: true, + schema: { type: "array", items: { type: "string" } }, + }, + ], + }), + now, + ); + db.prepare( + "INSERT INTO tool (id, scope_id, source_id, plugin_id, name, description, input_schema, output_schema, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ).run( + toolId, + scopeId, + sourceId, + "googleDiscovery", + "users.messages.list", + "Lists messages.", + encodeJson({ + type: "object", + properties: { + userId: { type: "string" }, + metadataHeaders: { type: "array", items: { type: "string" } }, + }, + }), + encodeJson({ $ref: "#/$defs/ListMessagesResponse" }), + now, + now, + ); + db.prepare( + "INSERT INTO definition (id, scope_id, source_id, plugin_id, name, schema, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + ).run( + `${sourceId}.ListMessagesResponse`, + scopeId, + sourceId, + "googleDiscovery", + "ListMessagesResponse", + encodeJson({ type: "object", properties: { messages: { type: "array" } } }), + now, + ); + + const migrated = oneShotMigrateGoogleDiscoveryToOpenApi(db); + + expect(migrated).toBe(1); + expect( + db.prepare("SELECT count(*) AS n FROM google_discovery_source").get() as { n: number }, + ).toMatchObject({ n: 0 }); + expect( + db.prepare("SELECT plugin_id, kind, url, can_refresh FROM source WHERE id = ?").get(sourceId), + ).toMatchObject({ + plugin_id: "openapi", + kind: "openapi", + url: "https://gmail.googleapis.com/", + can_refresh: 0, + }); + expect(db.prepare("SELECT plugin_id FROM tool WHERE id = ?").get(toolId)).toMatchObject({ + plugin_id: "openapi", + }); + + const sourceStorage = db + .prepare("SELECT data FROM plugin_storage WHERE collection = 'source' AND key = ?") + .get(sourceId) as { data: string }; + const sourceData = decodeMigratedSourceData(sourceStorage.data); + const spec = decodeMigratedSpec(sourceData.config.spec); + const operation = spec.paths["/gmail/v1/users/{userId}/messages"]?.get; + expect(operation).toMatchObject({ + operationId: "users.messages.list", + "x-executor-toolPath": "users.messages.list", + }); + expect(operation?.parameters).toContainEqual( + expect.objectContaining({ + name: "metadataHeaders", + in: "query", + style: "form", + explode: true, + }), + ); + expect(sourceData.config.oauth2).toMatchObject({ + connectionSlot: "oauth2:googleoauth2:connection", + }); + + expect( + db.prepare("SELECT key FROM plugin_storage WHERE collection = 'operation'").get(), + ).toMatchObject({ key: toolId }); + const credentialBindings = db + .prepare( + "SELECT slot_key, kind, secret_id, connection_id FROM credential_binding ORDER BY slot_key", + ) + .all(); + expect(credentialBindings).toEqual([ + { + slot_key: "oauth2:googleoauth2:client-id", + kind: "secret", + secret_id: "client-id-secret", + connection_id: null, + }, + { + slot_key: "oauth2:googleoauth2:client-secret", + kind: "secret", + secret_id: "client-secret-secret", + connection_id: null, + }, + { + slot_key: "oauth2:googleoauth2:connection", + kind: "connection", + secret_id: null, + connection_id: "google-discovery-oauth2-gmail_api", + }, + ]); + + db.close(); + }); +}); diff --git a/apps/local/src/server/google-discovery-openapi-migration.ts b/apps/local/src/server/google-discovery-openapi-migration.ts new file mode 100644 index 000000000..19424647c --- /dev/null +++ b/apps/local/src/server/google-discovery-openapi-migration.ts @@ -0,0 +1,581 @@ +import { Database } from "bun:sqlite"; +import { Option, Schema } from "effect"; +import { randomBytes } from "node:crypto"; + +const UnknownRecord = Schema.Record(Schema.String, Schema.Unknown); + +const GoogleDiscoveryConfig = Schema.Struct({ + discoveryUrl: Schema.optional(Schema.String), + service: Schema.optional(Schema.String), + version: Schema.optional(Schema.String), + rootUrl: Schema.String, + servicePath: Schema.optional(Schema.String), +}); + +const GoogleDiscoveryParameter = Schema.Struct({ + name: Schema.String, + location: Schema.String, + required: Schema.optional(Schema.Boolean), + repeated: Schema.optional(Schema.Boolean), + description: Schema.optional(Schema.String), + schema: Schema.optional(Schema.Unknown), +}); + +const GoogleDiscoveryBinding = Schema.Struct({ + method: Schema.String, + pathTemplate: Schema.String, + hasBody: Schema.optional(Schema.Boolean), + parameters: Schema.optional(Schema.Array(GoogleDiscoveryParameter)), +}); + +const JsonInputSchema = Schema.Struct({ + properties: Schema.optional(UnknownRecord), +}); + +const GoogleDiscoveryScopes = Schema.Array(Schema.String); + +const decodeGoogleDiscoveryConfig = Schema.decodeUnknownOption( + Schema.fromJsonString(GoogleDiscoveryConfig), +); +const decodeGoogleDiscoveryBinding = Schema.decodeUnknownOption( + Schema.fromJsonString(GoogleDiscoveryBinding), +); +const decodeInputSchema = Schema.decodeUnknownOption(Schema.fromJsonString(JsonInputSchema)); +const decodeUnknownJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); +const decodeUnknownRecord = Schema.decodeUnknownOption(UnknownRecord); +const decodeGoogleDiscoveryScopes = Schema.decodeUnknownOption( + Schema.fromJsonString(GoogleDiscoveryScopes), +); + +type GoogleDiscoveryConfig = typeof GoogleDiscoveryConfig.Type; +type GoogleDiscoveryBinding = typeof GoogleDiscoveryBinding.Type; + +type GoogleSourceRow = { + readonly id: string; + readonly scope_id: string; + readonly name: string; + readonly config: string; + readonly auth_kind: string; + readonly auth_connection_id: string | null; + readonly auth_client_id_secret_id: string | null; + readonly auth_client_secret_secret_id: string | null; + readonly auth_scopes: string | null; + readonly created_at: number; + readonly updated_at: number; +}; + +type GoogleBindingRow = { + readonly id: string; + readonly scope_id: string; + readonly source_id: string; + readonly binding: string; +}; + +type ToolRow = { + readonly id: string; + readonly name: string; + readonly description: string; + readonly input_schema: string | null; + readonly output_schema: string | null; +}; + +type DefinitionRow = { + readonly name: string; + readonly schema: string; +}; + +type CredentialRow = { + readonly name: string; + readonly kind: string; + readonly text_value: string | null; + readonly secret_id: string | null; + readonly secret_prefix: string | null; +}; + +type MigratedCredentialBinding = + | { + readonly slot: string; + readonly kind: "secret"; + readonly secretId: string; + readonly prefix?: string | null; + } + | { + readonly slot: string; + readonly kind: "connection"; + readonly connectionId: string; + }; + +type OpenApiParameter = { + readonly name: string; + readonly location: string; + readonly required: boolean; + readonly schema: unknown; + readonly style?: "form"; + readonly explode?: boolean; + readonly description?: string; +}; + +const textDecoder = new TextDecoder(); + +const decodeJsonColumnOption = ( + decode: (value: unknown) => Option.Option, + value: string | Uint8Array | null | undefined, +): Option.Option => { + if (!value) return Option.none(); + const text = typeof value === "string" ? value : textDecoder.decode(value); + return decode(text); +}; + +const decodeJsonColumnOrUndefined = ( + value: string | Uint8Array | null | undefined, +): unknown | undefined => Option.getOrUndefined(decodeJsonColumnOption(decodeUnknownJson, value)); + +const recordFromUnknown = (value: unknown): Record => + Option.getOrElse(decodeUnknownRecord(value), () => ({})); + +const nonEmptyStringOrUndefined = (value: string | undefined): string | undefined => + value && value.length > 0 ? value : undefined; + +const googleSchemaRef = (name: string): string => `#/$defs/${name}`; + +const openApiPluginStorageId = (collection: string, key: string): string => + JSON.stringify(["openapi", collection, key]); + +const openApiCredentialBindingId = (scopeId: string, sourceId: string, slot: string): string => + JSON.stringify(["openapi", scopeId, sourceId, slot]); + +const slugifyCredentialSlotPart = (value: string): string => + value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") || "default"; + +const openApiHeaderSlot = (name: string): string => `header:${slugifyCredentialSlotPart(name)}`; + +const openApiQueryParamSlot = (name: string): string => + `query_param:${slugifyCredentialSlotPart(name)}`; + +const googleOAuthSecuritySchemeName = "googleOAuth2"; + +const googleOAuthSlotPart = slugifyCredentialSlotPart(googleOAuthSecuritySchemeName); + +const googleOAuthClientIdSlot = `oauth2:${googleOAuthSlotPart}:client-id`; +const googleOAuthClientSecretSlot = `oauth2:${googleOAuthSlotPart}:client-secret`; +const googleOAuthConnectionSlot = `oauth2:${googleOAuthSlotPart}:connection`; + +const randomRowId = (): string => randomBytes(12).toString("hex"); + +const googleCredentialMap = ( + rows: readonly CredentialRow[], + slotForName: (name: string) => string, + bindings: MigratedCredentialBinding[], +): Record | undefined => { + const values: Record = {}; + for (const row of rows) { + if (row.kind === "text" && row.text_value != null) { + values[row.name] = row.text_value; + continue; + } + if (row.kind === "secret" && row.secret_id != null) { + const slot = slotForName(row.name); + values[row.name] = + row.secret_prefix != null + ? { kind: "binding", slot, prefix: row.secret_prefix } + : { kind: "binding", slot }; + bindings.push({ + slot, + kind: "secret", + secretId: row.secret_id, + prefix: row.secret_prefix, + }); + } + } + return Object.keys(values).length > 0 ? values : undefined; +}; + +const readSourceConfig = (source: GoogleSourceRow): GoogleDiscoveryConfig | null => { + const decoded = decodeJsonColumnOption(decodeGoogleDiscoveryConfig, source.config); + if (Option.isNone(decoded)) return null; + return decoded.value; +}; + +const readBinding = (row: GoogleBindingRow): GoogleDiscoveryBinding | null => { + const decoded = decodeJsonColumnOption(decodeGoogleDiscoveryBinding, row.binding); + if (Option.isNone(decoded)) return null; + return decoded.value; +}; + +const readBodySchema = (tool: ToolRow | undefined): Record => { + const decoded = decodeJsonColumnOption(decodeInputSchema, tool?.input_schema); + if (Option.isNone(decoded)) return {}; + return recordFromUnknown(decoded.value.properties?.body); +}; + +const readScopes = (value: string | null): readonly string[] => + Option.getOrElse(decodeJsonColumnOption(decodeGoogleDiscoveryScopes, value), () => []); + +const openApiParameters = ( + parameters: readonly (typeof GoogleDiscoveryParameter.Type)[] | undefined, +): readonly OpenApiParameter[] => + (parameters ?? []).map((parameter) => ({ + name: parameter.name, + location: parameter.location, + required: parameter.location === "path" ? true : parameter.required === true, + schema: parameter.schema ?? { type: "string" }, + ...(parameter.location === "query" + ? { style: "form" as const, explode: parameter.repeated === true } + : {}), + ...(parameter.description ? { description: parameter.description } : {}), + })); + +export const oneShotMigrateGoogleDiscoveryToOpenApi = (sqlite: Database): number => { + const table = sqlite + .query<{ name: string }, [string]>( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", + ) + .get("google_discovery_source"); + if (!table) return 0; + + const sources = sqlite + .query("SELECT * FROM google_discovery_source ORDER BY scope_id, id") + .all(); + let migrated = 0; + const migrateSource = (source: GoogleSourceRow): boolean => { + const config = readSourceConfig(source); + if (!config) return false; + + const baseUrl = new URL(config.servicePath ?? "", config.rootUrl).toString(); + const service = nonEmptyStringOrUndefined(config.service) ?? source.id; + const version = nonEmptyStringOrUndefined(config.version) ?? "v1"; + const discoveryUrl = nonEmptyStringOrUndefined(config.discoveryUrl); + + const bindings = sqlite + .query( + "SELECT * FROM google_discovery_binding WHERE scope_id = ? AND source_id = ? ORDER BY id", + ) + .all(source.scope_id, source.id); + if (bindings.length === 0) return false; + + const toolRows = new Map( + sqlite + .query( + "SELECT id, name, description, input_schema, output_schema FROM tool WHERE scope_id = ? AND source_id = ?", + ) + .all(source.scope_id, source.id) + .map((row) => [row.id, row] as const), + ); + const definitions = sqlite + .query( + "SELECT name, schema FROM definition WHERE scope_id = ? AND source_id = ? ORDER BY name", + ) + .all(source.scope_id, source.id); + + const paths: Record> = {}; + const operationRows: Array<{ readonly toolId: string; readonly binding: unknown }> = []; + + for (const row of bindings) { + const binding = readBinding(row); + if (!binding) continue; + + const method = binding.method.toLowerCase(); + const pathTemplate = binding.pathTemplate.startsWith("/") + ? binding.pathTemplate + : `/${binding.pathTemplate}`; + const tool = toolRows.get(row.id); + const toolPath = tool?.name ?? row.id.slice(source.id.length + 1); + const bodySchema = readBodySchema(tool); + const responseSchema = decodeJsonColumnOrUndefined(tool?.output_schema) ?? {}; + const parameters = openApiParameters(binding.parameters); + + paths[pathTemplate] ??= {}; + paths[pathTemplate]![method] = { + operationId: toolPath, + "x-executor-toolPath": toolPath, + ...(tool?.description ? { description: tool.description } : {}), + parameters: parameters.map(({ location, ...parameter }) => ({ + ...parameter, + in: location, + })), + ...(binding.hasBody === true + ? { + requestBody: { + required: false, + content: { + "application/json": { + schema: Object.keys(bodySchema).length > 0 ? bodySchema : { type: "object" }, + }, + }, + }, + } + : {}), + responses: { + "200": { + description: "Successful response", + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + }, + }; + + operationRows.push({ + toolId: row.id, + binding: { + method, + pathTemplate, + parameters, + ...(binding.hasBody === true + ? { + requestBody: { + required: false, + contentType: "application/json", + schema: Object.keys(bodySchema).length > 0 ? bodySchema : { type: "object" }, + contents: [ + { + contentType: "application/json", + schema: Object.keys(bodySchema).length > 0 ? bodySchema : { type: "object" }, + }, + ], + }, + } + : {}), + }, + }); + } + + if (operationRows.length === 0) return false; + + const schemaDefinitions = Object.fromEntries( + definitions.map((definition) => [ + definition.name, + decodeJsonColumnOrUndefined(definition.schema) ?? { + $ref: googleSchemaRef(definition.name), + }, + ]), + ); + const scopes = readScopes(source.auth_scopes); + const oauth2 = + source.auth_kind === "oauth2" && source.auth_connection_id && source.auth_client_id_secret_id + ? { + kind: "oauth2", + securitySchemeName: googleOAuthSecuritySchemeName, + flow: "authorizationCode", + authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth", + issuerUrl: "https://accounts.google.com", + tokenUrl: "https://oauth2.googleapis.com/token", + clientIdSlot: googleOAuthClientIdSlot, + clientSecretSlot: source.auth_client_secret_secret_id + ? googleOAuthClientSecretSlot + : null, + connectionSlot: googleOAuthConnectionSlot, + scopes, + } + : undefined; + + const spec = { + openapi: "3.1.0", + info: { title: source.name, version }, + servers: [{ url: baseUrl }], + paths, + components: { + schemas: schemaDefinitions, + ...(oauth2 + ? { + securitySchemes: { + [googleOAuthSecuritySchemeName]: { + type: "oauth2", + flows: { + authorizationCode: { + authorizationUrl: oauth2.authorizationUrl, + tokenUrl: oauth2.tokenUrl, + scopes: Object.fromEntries(scopes.map((scope) => [scope, ""])), + }, + }, + }, + }, + } + : {}), + }, + ...(oauth2 ? { security: [{ [googleOAuthSecuritySchemeName]: scopes }] } : {}), + "x-executor-origin": { + kind: "googleDiscovery", + ...(discoveryUrl ? { discoveryUrl } : {}), + service, + version, + }, + }; + + const credentialBindings: MigratedCredentialBinding[] = []; + + const headerRows = sqlite + .query( + "SELECT name, kind, text_value, secret_id, secret_prefix FROM google_discovery_source_credential_header WHERE scope_id = ? AND source_id = ?", + ) + .all(source.scope_id, source.id); + const queryParamRows = sqlite + .query( + "SELECT name, kind, text_value, secret_id, secret_prefix FROM google_discovery_source_credential_query_param WHERE scope_id = ? AND source_id = ?", + ) + .all(source.scope_id, source.id); + const headers = googleCredentialMap(headerRows, openApiHeaderSlot, credentialBindings); + const queryParams = googleCredentialMap( + queryParamRows, + openApiQueryParamSlot, + credentialBindings, + ); + + if (oauth2 && source.auth_client_id_secret_id) { + credentialBindings.push({ + slot: googleOAuthClientIdSlot, + kind: "secret", + secretId: source.auth_client_id_secret_id, + }); + if (source.auth_client_secret_secret_id) { + credentialBindings.push({ + slot: googleOAuthClientSecretSlot, + kind: "secret", + secretId: source.auth_client_secret_secret_id, + }); + } + if (source.auth_connection_id) { + credentialBindings.push({ + slot: googleOAuthConnectionSlot, + kind: "connection", + connectionId: source.auth_connection_id, + }); + } + } + + const now = Math.floor(Date.now() / 1000); + const sourceData = { + namespace: source.id, + scope: source.scope_id, + name: source.name, + config: { + spec: JSON.stringify(spec), + baseUrl, + namespace: source.id, + ...(headers ? { headers } : {}), + ...(queryParams ? { queryParams } : {}), + ...(oauth2 ? { oauth2 } : {}), + }, + }; + + sqlite.exec("BEGIN IMMEDIATE"); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: one-shot startup migration should leave each source atomic on write failure + try { + sqlite + .query( + "INSERT OR REPLACE INTO plugin_storage (plugin_id, collection, key, data, created_at, updated_at, row_id, id, scope_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .run( + "openapi", + "source", + source.id, + JSON.stringify(sourceData), + source.created_at ?? now, + now, + randomRowId(), + openApiPluginStorageId("source", source.id), + source.scope_id, + ); + + for (const operation of operationRows) { + sqlite + .query( + "INSERT OR REPLACE INTO plugin_storage (plugin_id, collection, key, data, created_at, updated_at, row_id, id, scope_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .run( + "openapi", + "operation", + operation.toolId, + JSON.stringify({ + toolId: operation.toolId, + sourceId: source.id, + binding: operation.binding, + }), + source.created_at ?? now, + now, + randomRowId(), + openApiPluginStorageId("operation", operation.toolId), + source.scope_id, + ); + } + + for (const binding of credentialBindings) { + const secretId = binding.kind === "secret" ? binding.secretId : null; + const secretScopeId = binding.kind === "secret" ? source.scope_id : null; + const connectionId = binding.kind === "connection" ? binding.connectionId : null; + sqlite + .query( + "INSERT OR REPLACE INTO credential_binding (plugin_id, source_id, source_scope_id, slot_key, kind, text_value, secret_id, secret_scope_id, connection_id, created_at, updated_at, row_id, id, scope_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .run( + "openapi", + source.id, + source.scope_id, + binding.slot, + binding.kind, + null, + secretId, + secretScopeId, + connectionId, + now, + now, + randomRowId(), + openApiCredentialBindingId(source.scope_id, source.id, binding.slot), + source.scope_id, + ); + } + + sqlite + .query( + "UPDATE source SET plugin_id = ?, kind = ?, url = ?, can_refresh = ?, can_edit = ?, updated_at = ? WHERE scope_id = ? AND id = ?", + ) + .run("openapi", "openapi", baseUrl, 0, 1, now, source.scope_id, source.id); + sqlite + .query("UPDATE tool SET plugin_id = ?, updated_at = ? WHERE scope_id = ? AND source_id = ?") + .run("openapi", now, source.scope_id, source.id); + sqlite + .query("UPDATE definition SET plugin_id = ? WHERE scope_id = ? AND source_id = ?") + .run("openapi", source.scope_id, source.id); + sqlite + .query("DELETE FROM google_discovery_binding WHERE scope_id = ? AND source_id = ?") + .run(source.scope_id, source.id); + sqlite + .query( + "DELETE FROM google_discovery_source_credential_header WHERE scope_id = ? AND source_id = ?", + ) + .run(source.scope_id, source.id); + sqlite + .query( + "DELETE FROM google_discovery_source_credential_query_param WHERE scope_id = ? AND source_id = ?", + ) + .run(source.scope_id, source.id); + sqlite + .query("DELETE FROM google_discovery_source WHERE scope_id = ? AND id = ?") + .run(source.scope_id, source.id); + sqlite.exec("COMMIT"); + } catch (cause) { + sqlite.exec("ROLLBACK"); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: synchronous SQLite migration rolls back then preserves the original startup failure + throw cause; + } + + return true; + }; + + for (const source of sources) { + if (migrateSource(source)) { + migrated++; + } + } + + if (migrated > 0) { + sqlite.exec("PRAGMA wal_checkpoint(TRUNCATE)"); + } + return migrated; +}; diff --git a/apps/marketing/package.json b/apps/marketing/package.json index 268ac40e9..6b3592652 100644 --- a/apps/marketing/package.json +++ b/apps/marketing/package.json @@ -16,15 +16,10 @@ "dependencies": { "@astrojs/cloudflare": "^13.0.0", "@astrojs/react": "^5.0.4", - "@executor-js/plugin-google-discovery": "workspace:*", - "@executor-js/plugin-graphql": "workspace:*", - "@executor-js/plugin-openapi": "workspace:*", "@executor-js/react": "workspace:*", - "@executor-js/sdk": "workspace:*", "@tailwindcss/vite": "^4.2.2", "astro": "^6.1.3", "clsx": "^2.1.1", - "effect": "catalog:", "motion": "^12.38.0", "posthog-js": "^1.372.5", "react": "^19.2.5", diff --git a/apps/marketing/src/pages/api/detect.ts b/apps/marketing/src/pages/api/detect.ts deleted file mode 100644 index 7bc0b4054..000000000 --- a/apps/marketing/src/pages/api/detect.ts +++ /dev/null @@ -1,156 +0,0 @@ -import type { APIRoute } from "astro"; -import { Effect } from "effect"; -import { createExecutor, type Tool } from "@executor-js/sdk"; -import { makeTestConfig } from "@executor-js/sdk/testing"; -import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; -import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; -import { googleDiscoveryHttpPlugin } from "@executor-js/plugin-google-discovery/api"; - -export const prerender = false; - -function inferMethod(toolName: string, pluginKey: string): string { - if (pluginKey === "graphql") { - return toolName.startsWith("mutation.") ? "mutation" : "query"; - } - if (pluginKey === "mcp") return "tool"; - - // OpenAPI / Google Discovery: infer from tool name - const lower = toolName.toLowerCase(); - if (/\.delete|\.remove|\.destroy/.test(lower)) return "DELETE"; - if (/\.create|\.insert|\.add|\.send|\.post/.test(lower)) return "POST"; - if (/\.update|\.patch/.test(lower)) return "PATCH"; - if (/\.put|\.merge|\.replace/.test(lower)) return "PUT"; - return "GET"; -} - -function inferPolicy(method: string, toolName: string): "read" | "write" | "destructive" { - const m = method.toUpperCase(); - if (m === "DELETE") return "destructive"; - const lower = toolName.toLowerCase(); - if (/delete|remove|destroy|drop|purge/.test(lower)) return "destructive"; - if (m === "GET" || m === "HEAD" || m === "QUERY") return "read"; - if (/list|get|query|check|read|search|find|fetch/.test(lower)) return "read"; - return "write"; -} - -function formatTools(tools: readonly Tool[]) { - return tools.map((t) => { - const method = inferMethod(t.name, t.pluginId); - return { - name: t.name, - desc: t.description?.slice(0, 80) || t.name, - method, - policy: inferPolicy(method, t.name), - }; - }); -} - -export const POST: APIRoute = async ({ request }) => { - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: Astro route converts request/parsing failures to a stable HTTP response - try { - const body = (await request.json()) as { url?: string }; - const url = body.url?.trim(); - if (!url) { - return new Response(JSON.stringify({ error: "URL is required" }), { - status: 400, - headers: { "Content-Type": "application/json" }, - }); - } - - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL constructor is the platform validator for request input - try { - new URL(url); - } catch { - return new Response(JSON.stringify({ error: "Invalid URL" }), { - status: 400, - headers: { "Content-Type": "application/json" }, - }); - } - - const program = Effect.gen(function* () { - const config = makeTestConfig({ - plugins: [openApiHttpPlugin(), graphqlHttpPlugin(), googleDiscoveryHttpPlugin()], - }); - const executor = yield* createExecutor(config); - - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: ensure executor cleanup runs after best-effort marketing detection - try { - // Detect what kind of source lives at this URL - const detected = yield* executor.sources.detect(url).pipe(Effect.timeout("10 seconds")); - - if (!detected || detected.length === 0) return null; - - const match = detected[0]; - - // Add source to register its tools (Google Discovery needs auth so skip) - if (match.kind === "openapi") { - yield* executor.openapi.addSpec({ - spec: { kind: "url", url: match.endpoint }, - name: match.name, - namespace: match.namespace, - baseUrl: match.endpoint, - scope: "test-scope", - }); - } else if (match.kind === "graphql") { - yield* executor.graphql.addSource({ - endpoint: match.endpoint, - name: match.name, - namespace: match.namespace, - scope: "test-scope", - }); - } else { - // For kinds we can't fully add (e.g. Google Discovery needs auth), - // return just the detection metadata - return { - kind: match.kind, - name: match.name, - count: 0, - tools: [], - }; - } - - const tools = yield* executor.tools.list({ - sourceId: match.namespace, - }); - const mapped = formatTools(tools); - - return { - kind: match.kind, - name: match.name, - count: mapped.length, - tools: mapped.slice(0, 50), - }; - } finally { - yield* executor.close(); - } - }); - - const result = await Effect.runPromise( - program.pipe( - Effect.catchCause(() => Effect.succeed(null)), - Effect.timeout("25 seconds"), - Effect.catchCause(() => Effect.succeed(null)), - ), - ); - - if (!result) { - return new Response( - JSON.stringify({ - error: - "Could not detect an API at this URL. Try an OpenAPI spec, GraphQL endpoint, or Google Discovery document.", - }), - { status: 404, headers: { "Content-Type": "application/json" } }, - ); - } - - return new Response(JSON.stringify(result), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } catch { - return new Response(JSON.stringify({ error: "Detection failed" }), { - status: 500, - headers: { "Content-Type": "application/json" }, - }); - } -}; diff --git a/bun.lock b/bun.lock index 2deff85cc..c1b218288 100644 --- a/bun.lock +++ b/bun.lock @@ -129,7 +129,6 @@ "@executor-js/local": "workspace:*", "@executor-js/plugin-desktop-settings": "workspace:*", "@executor-js/plugin-file-secrets": "workspace:*", - "@executor-js/plugin-google-discovery": "workspace:*", "@executor-js/plugin-graphql": "workspace:*", "@executor-js/plugin-keychain": "workspace:*", "@executor-js/plugin-mcp": "workspace:*", @@ -163,7 +162,6 @@ "@executor-js/plugin-desktop-settings": "workspace:*", "@executor-js/plugin-example": "workspace:*", "@executor-js/plugin-file-secrets": "workspace:*", - "@executor-js/plugin-google-discovery": "workspace:*", "@executor-js/plugin-graphql": "workspace:*", "@executor-js/plugin-keychain": "workspace:*", "@executor-js/plugin-mcp": "workspace:*", @@ -205,15 +203,10 @@ "dependencies": { "@astrojs/cloudflare": "^13.0.0", "@astrojs/react": "^5.0.4", - "@executor-js/plugin-google-discovery": "workspace:*", - "@executor-js/plugin-graphql": "workspace:*", - "@executor-js/plugin-openapi": "workspace:*", "@executor-js/react": "workspace:*", - "@executor-js/sdk": "workspace:*", "@tailwindcss/vite": "^4.2.2", "astro": "^6.1.3", "clsx": "^2.1.1", - "effect": "catalog:", "motion": "^12.38.0", "posthog-js": "^1.372.5", "react": "^19.2.5", @@ -234,7 +227,6 @@ "version": "0.0.19", "dependencies": { "@executor-js/plugin-file-secrets": "workspace:*", - "@executor-js/plugin-google-discovery": "workspace:*", "@executor-js/plugin-graphql": "workspace:*", "@executor-js/plugin-keychain": "workspace:*", "@executor-js/plugin-mcp": "workspace:*", @@ -643,41 +635,6 @@ "vitest": "catalog:", }, }, - "packages/plugins/google-discovery": { - "name": "@executor-js/plugin-google-discovery", - "version": "1.4.33", - "dependencies": { - "@executor-js/sdk": "workspace:*", - "effect": "catalog:", - }, - "devDependencies": { - "@effect/atom-react": "catalog:", - "@effect/platform-node": "catalog:", - "@effect/vitest": "catalog:", - "@executor-js/api": "workspace:*", - "@executor-js/react": "workspace:*", - "@types/node": "catalog:", - "@types/react": "catalog:", - "bun-types": "catalog:", - "react": "catalog:", - "tsup": "catalog:", - "vitest": "catalog:", - }, - "peerDependencies": { - "@effect/atom-react": "catalog:", - "@executor-js/api": "workspace:*", - "@executor-js/react": "workspace:*", - "@tanstack/react-router": "catalog:", - "react": "catalog:", - }, - "optionalPeers": [ - "@effect/atom-react", - "@executor-js/api", - "@executor-js/react", - "@tanstack/react-router", - "react", - ], - }, "packages/plugins/graphql": { "name": "@executor-js/plugin-graphql", "version": "1.4.33", @@ -1370,8 +1327,6 @@ "@executor-js/plugin-file-secrets": ["@executor-js/plugin-file-secrets@workspace:packages/plugins/file-secrets"], - "@executor-js/plugin-google-discovery": ["@executor-js/plugin-google-discovery@workspace:packages/plugins/google-discovery"], - "@executor-js/plugin-graphql": ["@executor-js/plugin-graphql@workspace:packages/plugins/graphql"], "@executor-js/plugin-keychain": ["@executor-js/plugin-keychain@workspace:packages/plugins/keychain"], diff --git a/examples/all-plugins/package.json b/examples/all-plugins/package.json index 5f282c020..f15d75ff7 100644 --- a/examples/all-plugins/package.json +++ b/examples/all-plugins/package.json @@ -10,7 +10,6 @@ }, "dependencies": { "@executor-js/plugin-file-secrets": "workspace:*", - "@executor-js/plugin-google-discovery": "workspace:*", "@executor-js/plugin-graphql": "workspace:*", "@executor-js/plugin-keychain": "workspace:*", "@executor-js/plugin-mcp": "workspace:*", diff --git a/examples/all-plugins/src/main.ts b/examples/all-plugins/src/main.ts index c8c4d2a1e..747976d72 100644 --- a/examples/all-plugins/src/main.ts +++ b/examples/all-plugins/src/main.ts @@ -22,7 +22,6 @@ import { Cause, Effect } from "effect"; import { SecretId, Scope, ScopeId, SetSecretInput, createExecutor } from "@executor-js/sdk"; import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets"; -import { googleDiscoveryPlugin } from "@executor-js/plugin-google-discovery"; import { graphqlPlugin } from "@executor-js/plugin-graphql"; import { keychainPlugin } from "@executor-js/plugin-keychain"; import { mcpPlugin } from "@executor-js/plugin-mcp"; @@ -54,9 +53,8 @@ const plugins = [ // Source plugins — these declare their own schemas (tables) and // register tools dynamically when the user adds a spec / connects - // to a server / runs discovery. + // to a server / imports a discovery document. graphqlPlugin(), - googleDiscoveryPlugin(), mcpPlugin({ dangerouslyAllowStdioMCP: false }), openApiPlugin(), @@ -172,7 +170,6 @@ const program = Effect.gen(function* () { console.log(" executor.fileSecrets ", typeof executor.fileSecrets); console.log(" executor.onepassword ", typeof executor.onepassword); console.log(" executor.graphql ", typeof executor.graphql); - console.log(" executor.googleDiscovery ", typeof executor.googleDiscovery); console.log(" executor.mcp ", typeof executor.mcp); console.log(" executor.openapi ", typeof executor.openapi); @@ -373,7 +370,7 @@ const program = Effect.gen(function* () { ); // ------------------------------------------------------------------------- - // MCP, Google Discovery, 1Password — shown but not exercised (they need + // MCP, Google OAuth, 1Password — shown but not exercised (they need // real external infrastructure). Their extension methods exist, and // calling them would register real dynamic sources the same way. // ------------------------------------------------------------------------- @@ -388,7 +385,7 @@ const program = Effect.gen(function* () { console.log(" executor.fileSecrets.filePath: ", executor.fileSecrets.filePath); // executor.mcp.addSource({ connector: { kind: "remote", endpoint: "..." } }); - // executor.googleDiscovery.addSource({ discoveryUrl: "..." }); + // executor.openapi.addSpec({ spec: { kind: "googleDiscovery", url: "..." }, ... }); // executor.onepassword.configure({ auth: { kind: "desktop-app", accountName: "..." }, vaultId: "..." }); // ------------------------------------------------------------------------- diff --git a/packages/core/api/src/handlers/oauth.ts b/packages/core/api/src/handlers/oauth.ts index ab53dd835..6013512c4 100644 --- a/packages/core/api/src/handlers/oauth.ts +++ b/packages/core/api/src/handlers/oauth.ts @@ -1,7 +1,7 @@ // --------------------------------------------------------------------------- // Shared OAuth HTTP handlers — thin forwarders over `executor.oauth.*`. -// Replaces the four per-plugin copies (mcp / openapi / google-discovery -// each had its own start / complete / callback handler). +// Replaces the per-plugin copies that each had their own start / complete / +// callback handler. // --------------------------------------------------------------------------- import { HttpApiBuilder } from "effect/unstable/httpapi"; diff --git a/packages/core/api/src/oauth/api.ts b/packages/core/api/src/oauth/api.ts index 2d55fd915..a7af770b1 100644 --- a/packages/core/api/src/oauth/api.ts +++ b/packages/core/api/src/oauth/api.ts @@ -3,8 +3,8 @@ // `/scopes/:scopeId/oauth/{probe,start,complete,callback}` for every // plugin that needs OAuth. `pluginId` lives on the request body so the // completion callback can route to the right plugin at persist time. -// Replaces the four per-plugin copies that lived under -// `/scopes/:scopeId/{mcp,openapi,graphql,google-discovery}/oauth/*`. +// Replaces the per-plugin copies that lived under +// `/scopes/:scopeId/{mcp,openapi,graphql}/oauth/*`. // --------------------------------------------------------------------------- import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi"; diff --git a/packages/core/sdk/README.md b/packages/core/sdk/README.md index 01f0732dd..07f084227 100644 --- a/packages/core/sdk/README.md +++ b/packages/core/sdk/README.md @@ -133,9 +133,8 @@ The same pattern is what every shipped `@executor-js/plugin-*` package does inte These plugin packages are published from the monorepo: - [`@executor-js/plugin-mcp`](https://www.npmjs.com/package/@executor-js/plugin-mcp) — Model Context Protocol sources (stdio + remote) -- [`@executor-js/plugin-openapi`](https://www.npmjs.com/package/@executor-js/plugin-openapi) — OpenAPI specs as tools +- [`@executor-js/plugin-openapi`](https://www.npmjs.com/package/@executor-js/plugin-openapi) — OpenAPI specs and Google Discovery documents as tools - [`@executor-js/plugin-graphql`](https://www.npmjs.com/package/@executor-js/plugin-graphql) — GraphQL endpoints as tools -- [`@executor-js/plugin-google-discovery`](https://www.npmjs.com/package/@executor-js/plugin-google-discovery) — Google Discovery APIs - [`@executor-js/plugin-file-secrets`](https://www.npmjs.com/package/@executor-js/plugin-file-secrets) — file-backed secret store - [`@executor-js/plugin-keychain`](https://www.npmjs.com/package/@executor-js/plugin-keychain) — OS keychain secret store - [`@executor-js/plugin-onepassword`](https://www.npmjs.com/package/@executor-js/plugin-onepassword) — 1Password secret source diff --git a/packages/core/sdk/src/client.ts b/packages/core/sdk/src/client.ts index ed07cbd01..afab76f35 100644 --- a/packages/core/sdk/src/client.ts +++ b/packages/core/sdk/src/client.ts @@ -151,8 +151,8 @@ export interface ClientPluginSpec { readonly widgets?: readonly WidgetDecl[]; readonly slots?: Record; /** Source plugin contribution — populated by plugins that expose - * `kind` rows in the core `source` table (openapi, mcp, graphql, - * google-discovery). The host's sources page derives its provider + * `kind` rows in the core `source` table (openapi, mcp, graphql). + * The host's sources page derives its provider * list from the union of every loaded plugin's `sourcePlugin`. */ readonly sourcePlugin?: SourcePlugin; /** Secret provider plugin contribution — populated by plugins that diff --git a/packages/core/sdk/src/core-tools.ts b/packages/core/sdk/src/core-tools.ts index cdf70c6fd..6f7141dfd 100644 --- a/packages/core/sdk/src/core-tools.ts +++ b/packages/core/sdk/src/core-tools.ts @@ -643,7 +643,7 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { tool({ name: "sources.presets", description: - "List the same popular source presets shown in Executor web's Connect dialog. Use this before asking the user what to connect; filter with `query` for names like GitHub, Stripe, Axiom, Google Calendar, Linear, or OpenAI. For MCP and GraphQL presets, pass `endpoint` to the probe/add tools. For OpenAPI and Google Discovery presets, pass `url` to the preview/probe and add tools. For stdio MCP presets, use the returned command/args/env.", + "List the same popular source presets shown in Executor web's Connect dialog. Use this before asking the user what to connect; filter with `query` for names like GitHub, Stripe, Axiom, Google Calendar, Linear, or OpenAI. For OpenAPI presets, including Google Discovery URLs, pass `url` to the preview/probe and add tools. For MCP and GraphQL presets, pass `endpoint`. For stdio MCP presets, use the returned command/args/env.", inputSchema: SourcesPresetsInputStd, outputSchema: SourcesPresetsOutputStd, execute: (input, { ctx }) => @@ -669,7 +669,7 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { tool({ name: "sources.configure", description: - 'Low-level escape hatch for configuring an existing source through its owning plugin. Prefer plugin-specific tools such as `openapi.configureSource`, `graphql.configureSource`, `mcp.configureSource`, or `googleDiscovery.configureSource`; this accepts plugin config as `unknown` for repair and compatibility cases. Use `secrets.create`/`oauth.start` first for sensitive inputs. Pass secret refs as `{kind:"secret", secretId}` and OAuth connections as `{kind:"connection", connectionId}` when the plugin schema supports them.', + 'Low-level escape hatch for configuring an existing source through its owning plugin. Prefer plugin-specific tools such as `openapi.configureSource`, `graphql.configureSource`, or `mcp.configureSource`; this accepts plugin config as `unknown` for repair and compatibility cases. Use `secrets.create`/`oauth.start` first for sensitive inputs. Pass secret refs as `{kind:"secret", secretId}` and OAuth connections as `{kind:"connection", connectionId}` when the plugin schema supports them.', annotations: { requiresApproval: true, approvalDescription: "Configure an Executor source", diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index cfd69b94d..b7c8c7ad3 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -3200,7 +3200,6 @@ export const createExecutor = executor.openapi.addSource - // googleDiscovery.addSource -> executor.googleDiscovery.addSource const decls = plugin.staticSources ? plugin.staticSources(extension) : []; for (const source of decls) { const mountUnderExecutor = source.kind === "executor"; diff --git a/packages/core/sdk/src/oauth-helpers.test.ts b/packages/core/sdk/src/oauth-helpers.test.ts index 908c55b0f..ac7f02383 100644 --- a/packages/core/sdk/src/oauth-helpers.test.ts +++ b/packages/core/sdk/src/oauth-helpers.test.ts @@ -1,6 +1,6 @@ // --------------------------------------------------------------------------- // Fidelity suite — locks in every edge case the prior hand-rolled -// google-discovery oauth.ts handled, so future "simplifications" of the +// Google OAuth integrations handled, so future "simplifications" of the // shared helpers fail loudly instead of silently breaking refresh / parsing / // provider-specific quirks. // --------------------------------------------------------------------------- diff --git a/packages/core/sdk/src/oauth.ts b/packages/core/sdk/src/oauth.ts index 8e1f52e79..e0b216f96 100644 --- a/packages/core/sdk/src/oauth.ts +++ b/packages/core/sdk/src/oauth.ts @@ -12,8 +12,7 @@ // `ctx.oauth.complete`, and at invoke time the plugin calls // `ctx.connections.accessToken(connectionId)` for a fresh Bearer. // -// This replaces four per-plugin state machines (one each in mcp, -// openapi, google-discovery, graphql) that were all shading the same +// This replaces per-plugin state machines that were all shading the same // lifecycle. // --------------------------------------------------------------------------- diff --git a/packages/plugins/google-discovery/CHANGELOG.md b/packages/plugins/google-discovery/CHANGELOG.md deleted file mode 100644 index f3098a700..000000000 --- a/packages/plugins/google-discovery/CHANGELOG.md +++ /dev/null @@ -1,4 +0,0 @@ -# @executor-js/plugin-google-discovery changelog - -This file exists for Changesets release workflow compatibility. -Canonical user-facing release notes are published on GitHub Releases. diff --git a/packages/plugins/google-discovery/README.md b/packages/plugins/google-discovery/README.md deleted file mode 100644 index 175cb98a9..000000000 --- a/packages/plugins/google-discovery/README.md +++ /dev/null @@ -1,79 +0,0 @@ -# @executor-js/plugin-google-discovery - -Turn any [Google Discovery API](https://developers.google.com/discovery) (Calendar, Gmail, Drive, Sheets, etc.) into a set of executor tools. Handles the discovery document, OAuth flow, and per-request token binding. - -## Install - -```sh -bun add @executor-js/sdk @executor-js/plugin-google-discovery -# or -npm install @executor-js/sdk @executor-js/plugin-google-discovery -``` - -## Usage - -```ts -import { createExecutor } from "@executor-js/sdk"; -import { googleDiscoveryPlugin } from "@executor-js/plugin-google-discovery"; -import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets"; - -const executor = await createExecutor({ - onElicitation: "accept-all", - plugins: [fileSecretsPlugin(), googleDiscoveryPlugin()] as const, -}); - -const scope = executor.scopes[0]!.id; - -// Store the OAuth client credentials as secrets first — the plugin -// references them by id at sign-in time so client_id/client_secret never -// live in your config files. -await executor.secrets.set({ - id: "google-client-id", - name: "Google OAuth Client ID", - value: process.env.GOOGLE_CLIENT_ID!, - scope, -}); -await executor.secrets.set({ - id: "google-client-secret", - name: "Google OAuth Client Secret", - value: process.env.GOOGLE_CLIENT_SECRET!, - scope, -}); - -// Mint a Connection through executor.connections.create(...) — usually -// done by the OAuth start/callback flow on your host. For type-safety -// here we declare a placeholder id. -declare const connectionId: string; - -await executor.googleDiscovery.addSource({ - scope, - name: "Google Calendar", - discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest", - namespace: "calendar", - auth: { - kind: "oauth2", - connectionId, - clientIdSecretId: "google-client-id", - clientSecretSecretId: "google-client-secret", - scopes: ["https://www.googleapis.com/auth/calendar.readonly"], - }, -}); - -const tools = await executor.tools.list(); -``` - -## Using with Effect - -If you're building on `@executor-js/sdk/core` (the raw Effect entry), import this plugin from its `/core` subpath instead — it returns the Effect-shaped plugin with `Effect.Effect<...>`-returning methods rather than promisified wrappers: - -```ts -import { googleDiscoveryPlugin } from "@executor-js/plugin-google-discovery/core"; -``` - -## Status - -Pre-`1.0`. APIs may still change between beta releases. Part of the [executor monorepo](https://github.com/RhysSullivan/executor). - -## License - -MIT diff --git a/packages/plugins/google-discovery/fixtures/drive.json b/packages/plugins/google-discovery/fixtures/drive.json deleted file mode 100644 index 71d30ab5c..000000000 --- a/packages/plugins/google-discovery/fixtures/drive.json +++ /dev/null @@ -1,92 +0,0 @@ -{ - "name": "drive", - "version": "v3", - "title": "Google Drive", - "rootUrl": "https://www.googleapis.com/", - "servicePath": "drive/v3/", - "parameters": { - "prettyPrint": { - "location": "query", - "type": "boolean", - "description": "Returns response with indentations and line breaks." - } - }, - "resources": { - "files": { - "methods": { - "get": { - "id": "drive.files.get", - "path": "files/{fileId}", - "httpMethod": "GET", - "parameters": { - "fileId": { - "location": "path", - "type": "string", - "required": true - }, - "fields": { - "location": "query", - "type": "string" - } - }, - "response": { - "$ref": "File" - }, - "scopes": ["https://www.googleapis.com/auth/drive.readonly"] - }, - "update": { - "id": "drive.files.update", - "path": "files/{fileId}", - "httpMethod": "PATCH", - "parameters": { - "fileId": { - "location": "path", - "type": "string", - "required": true - } - }, - "request": { - "$ref": "UpdateFileRequest" - }, - "response": { - "$ref": "File" - }, - "scopes": ["https://www.googleapis.com/auth/drive"] - } - } - } - }, - "schemas": { - "File": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - } - }, - "UpdateFileRequest": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - } - } - }, - "auth": { - "oauth2": { - "scopes": { - "https://www.googleapis.com/auth/drive": { - "description": "See, edit, create, and delete all of your Google Drive files" - }, - "https://www.googleapis.com/auth/drive.readonly": { - "description": "See and download all your Google Drive files" - } - } - } - } -} diff --git a/packages/plugins/google-discovery/package.json b/packages/plugins/google-discovery/package.json deleted file mode 100644 index c01161cbd..000000000 --- a/packages/plugins/google-discovery/package.json +++ /dev/null @@ -1,97 +0,0 @@ -{ - "name": "@executor-js/plugin-google-discovery", - "version": "1.4.33", - "homepage": "https://github.com/RhysSullivan/executor/tree/main/packages/plugins/google-discovery", - "bugs": { - "url": "https://github.com/RhysSullivan/executor/issues" - }, - "license": "MIT", - "repository": { - "type": "git", - "url": "git+https://github.com/RhysSullivan/executor.git", - "directory": "packages/plugins/google-discovery" - }, - "files": [ - "dist" - ], - "type": "module", - "exports": { - ".": "./src/sdk/index.ts", - "./promise": "./src/promise.ts", - "./api": "./src/api/index.ts", - "./react": "./src/react/index.ts", - "./presets": "./src/sdk/presets.ts", - "./client": "./src/react/plugin-client.tsx" - }, - "publishConfig": { - "access": "public", - "exports": { - ".": { - "import": { - "types": "./dist/promise.d.ts", - "default": "./dist/index.js" - } - }, - "./core": { - "import": { - "types": "./dist/sdk/index.d.ts", - "default": "./dist/core.js" - } - }, - "./client": { - "import": { - "types": "./dist/react/plugin-client.d.ts", - "default": "./dist/client.js" - } - } - } - }, - "scripts": { - "build": "tsup && (tsc --declaration --emitDeclarationOnly --outDir dist --rootDir src || true)", - "typecheck": "tsgo --noEmit", - "test": "vitest run", - "test:watch": "vitest", - "typecheck:slow": "bunx tsc --noEmit -p tsconfig.json" - }, - "dependencies": { - "@executor-js/sdk": "workspace:*", - "effect": "catalog:" - }, - "devDependencies": { - "@effect/atom-react": "catalog:", - "@effect/platform-node": "catalog:", - "@effect/vitest": "catalog:", - "@executor-js/api": "workspace:*", - "@executor-js/react": "workspace:*", - "@types/node": "catalog:", - "@types/react": "catalog:", - "bun-types": "catalog:", - "react": "catalog:", - "tsup": "catalog:", - "vitest": "catalog:" - }, - "peerDependencies": { - "@effect/atom-react": "catalog:", - "@executor-js/api": "workspace:*", - "@executor-js/react": "workspace:*", - "@tanstack/react-router": "catalog:", - "react": "catalog:" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "@effect/atom-react": { - "optional": true - }, - "@tanstack/react-router": { - "optional": true - }, - "@executor-js/api": { - "optional": true - }, - "@executor-js/react": { - "optional": true - } - } -} diff --git a/packages/plugins/google-discovery/src/api/group.ts b/packages/plugins/google-discovery/src/api/group.ts deleted file mode 100644 index 5d2509314..000000000 --- a/packages/plugins/google-discovery/src/api/group.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi"; -import { Schema } from "effect"; -import { InternalError, ScopeId, SecretBackedValue } from "@executor-js/sdk/shared"; -import { GoogleDiscoveryParseError, GoogleDiscoverySourceError } from "../sdk/errors"; -import { GoogleDiscoveryStoredSourceSchema } from "../sdk/stored-source"; - -export { HttpApiSchema }; - -const DiscoveryCredentialsPayload = Schema.Struct({ - headers: Schema.optional(Schema.Record(Schema.String, SecretBackedValue)), - queryParams: Schema.optional(Schema.Record(Schema.String, SecretBackedValue)), -}); - -const AuthPayload = Schema.Union([ - Schema.Struct({ - kind: Schema.Literal("none"), - }), - Schema.Struct({ - kind: Schema.Literal("oauth2"), - connectionId: Schema.String, - clientIdSecretId: Schema.String, - clientSecretSecretId: Schema.NullOr(Schema.String), - scopes: Schema.Array(Schema.String), - }), -]); - -const ProbePayload = Schema.Struct({ - discoveryUrl: Schema.String, - credentials: Schema.optional(DiscoveryCredentialsPayload), -}); - -const ProbeOperation = Schema.Struct({ - toolPath: Schema.String, - method: Schema.String, - pathTemplate: Schema.String, - description: Schema.NullOr(Schema.String), -}); - -const ProbeResponse = Schema.Struct({ - name: Schema.String, - title: Schema.NullOr(Schema.String), - service: Schema.String, - version: Schema.String, - toolCount: Schema.Number, - scopes: Schema.Array(Schema.String), - operations: Schema.Array(ProbeOperation), -}); - -const AddSourcePayload = Schema.Struct({ - name: Schema.String, - discoveryUrl: Schema.String, - credentials: Schema.optional(DiscoveryCredentialsPayload), - namespace: Schema.optional(Schema.String), - auth: AuthPayload, -}); - -const AddSourceResponse = Schema.Struct({ - toolCount: Schema.Number, - namespace: Schema.String, -}); - -const UpdateSourcePayload = Schema.Struct({ - name: Schema.optional(Schema.String), - auth: Schema.optional(AuthPayload), -}); - -const UpdateSourceResponse = Schema.Struct({ - updated: Schema.Boolean, -}); - -// OAuth start/complete/callback payloads/responses live on the shared -// `/scopes/:scopeId/oauth/*` group in `@executor-js/api` now — no -// plugin-specific OAuth schemas needed here. - -export class GoogleDiscoveryApiError extends Schema.TaggedErrorClass()( - "GoogleDiscoveryApiError", - { - message: Schema.String, - }, - { httpApiStatus: 400 }, -) {} - -const GoogleDiscoveryErrors = [ - InternalError, - GoogleDiscoveryApiError, - GoogleDiscoveryParseError, - GoogleDiscoverySourceError, -] as const; - -// --------------------------------------------------------------------------- -// Group -// -// Domain errors + the shared opaque 500 (`InternalError`) are declared -// once at the group level via `.addError(...)` — every endpoint -// inherits them. The domain error carries its HTTP status via -// `HttpApiSchema.annotations`; `InternalError` is the public 5xx -// surface, translated from `StorageError` at the HTTP edge by -// `withCapture`. No per-endpoint `.addError(...)`, no per-handler -// InternalError — handlers just `return yield* ext.foo(...)`. -// --------------------------------------------------------------------------- - -export const GoogleDiscoveryGroup = HttpApiGroup.make("googleDiscovery") - .add( - HttpApiEndpoint.post("probeDiscovery", "/scopes/:scopeId/google-discovery/probe", { - params: { scopeId: ScopeId }, - payload: ProbePayload, - success: ProbeResponse, - error: GoogleDiscoveryErrors, - }), - ) - .add( - HttpApiEndpoint.post("addSource", "/scopes/:scopeId/google-discovery/sources", { - params: { scopeId: ScopeId }, - payload: AddSourcePayload, - success: AddSourceResponse, - error: GoogleDiscoveryErrors, - }), - ) - .add( - HttpApiEndpoint.patch("updateSource", "/scopes/:scopeId/google-discovery/sources/:namespace", { - params: { scopeId: ScopeId, namespace: Schema.String }, - payload: UpdateSourcePayload, - success: UpdateSourceResponse, - error: GoogleDiscoveryErrors, - }), - ) - .add( - HttpApiEndpoint.get("getSource", "/scopes/:scopeId/google-discovery/sources/:namespace", { - params: { scopeId: ScopeId, namespace: Schema.String }, - success: Schema.NullOr(GoogleDiscoveryStoredSourceSchema), - error: GoogleDiscoveryErrors, - }), - ); -// Errors are declared per endpoint in Effect v4. diff --git a/packages/plugins/google-discovery/src/api/handlers.test.ts b/packages/plugins/google-discovery/src/api/handlers.test.ts deleted file mode 100644 index 8d1faad7a..000000000 --- a/packages/plugins/google-discovery/src/api/handlers.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -// --------------------------------------------------------------------------- -// Handler-level integration test for the Google Discovery group. -// -// Verifies the layer wiring stays coherent end-to-end: the handlers -// pull the wrapped extension from the service, and any un-caught cause -// lands in the observability middleware — producing a 500 whose body is -// the opaque `InternalError` schema (no internal leakage). -// --------------------------------------------------------------------------- - -import { HttpApiBuilder } from "effect/unstable/httpapi"; -import { HttpRouter, HttpServer } from "effect/unstable/http"; -import { describe, expect, it } from "@effect/vitest"; -import { Context, Effect, Layer } from "effect"; - -import { addGroup, observabilityMiddleware } from "@executor-js/api"; -import { CoreHandlers, ExecutionEngineService, ExecutorService } from "@executor-js/api/server"; -import type { GoogleDiscoveryPluginExtension } from "../sdk/plugin"; -import { GoogleDiscoveryStoredSourceData } from "../sdk/types"; -import { GoogleDiscoveryExtensionService, GoogleDiscoveryHandlers } from "./handlers"; -import { GoogleDiscoveryGroup } from "./group"; - -// oxlint-disable-next-line executor/no-error-constructor -- boundary: test injects a defect to verify opaque handler error responses -const unused = Effect.die(new Error("unused")); - -const failingExtension: GoogleDiscoveryPluginExtension = { - // oxlint-disable-next-line executor/no-error-constructor -- boundary: test injects a defect to verify opaque handler error responses - probeDiscovery: () => Effect.die(new Error("Not implemented")), - addSource: () => unused, - removeSource: (_namespace: string, _scope: string) => unused, - getSource: (_namespace: string, _scope: string) => Effect.succeed(null), - updateSource: () => unused, -}; - -const Api = addGroup(GoogleDiscoveryGroup); -const UnusedExecutor = Layer.succeed(ExecutorService)({} as ExecutorService["Service"]); -const UnusedExecutionEngine = Layer.succeed(ExecutionEngineService)( - {} as ExecutionEngineService["Service"], -); -const HandlerContext = Context.make(ExecutorService, {} as ExecutorService["Service"]).pipe( - Context.add(ExecutionEngineService, {} as ExecutionEngineService["Service"]), - Context.add(GoogleDiscoveryExtensionService, failingExtension), -); - -// `acquireRelease` keeps disposal inside the Effect scope — no -// try/finally, no per-test cleanup plumbing. `it.scoped` closes the -// scope for us. -const WebHandler = Effect.acquireRelease( - Effect.sync(() => - HttpRouter.toWebHandler( - HttpApiBuilder.layer(Api).pipe( - Layer.provide(CoreHandlers), - Layer.provide(GoogleDiscoveryHandlers), - Layer.provide(observabilityMiddleware(Api)), - Layer.provide(UnusedExecutor), - Layer.provide(UnusedExecutionEngine), - Layer.provide(Layer.succeed(GoogleDiscoveryExtensionService, failingExtension)), - Layer.provideMerge(HttpServer.layerServices), - Layer.provideMerge(Layer.succeed(HttpRouter.RouterConfig)({ maxParamLength: 1000 })), - ), - ), - ), - (web) => Effect.promise(() => web.dispose()), -); - -describe("GoogleDiscoveryHandlers", () => { - it.effect("encodes stored source details returned from the SDK store", () => - Effect.gen(function* () { - const extension: GoogleDiscoveryPluginExtension = { - ...failingExtension, - getSource: (namespace, scope) => - Effect.succeed({ - namespace, - scope, - name: "Calendar", - config: GoogleDiscoveryStoredSourceData.make({ - name: "Calendar", - discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest", - service: "calendar", - version: "v3", - rootUrl: "https://www.googleapis.com/", - servicePath: "calendar/v3/", - auth: { kind: "none" }, - }), - }), - }; - const context = Context.make(ExecutorService, {} as ExecutorService["Service"]).pipe( - Context.add(ExecutionEngineService, {} as ExecutionEngineService["Service"]), - Context.add(GoogleDiscoveryExtensionService, extension), - ); - const web = yield* Effect.acquireRelease( - Effect.sync(() => - HttpRouter.toWebHandler( - HttpApiBuilder.layer(Api).pipe( - Layer.provide(CoreHandlers), - Layer.provide(GoogleDiscoveryHandlers), - Layer.provide(observabilityMiddleware(Api)), - Layer.provide(UnusedExecutor), - Layer.provide(UnusedExecutionEngine), - Layer.provide(Layer.succeed(GoogleDiscoveryExtensionService, extension)), - Layer.provideMerge(HttpServer.layerServices), - Layer.provideMerge(Layer.succeed(HttpRouter.RouterConfig)({ maxParamLength: 1000 })), - ), - ), - ), - (webHandler) => Effect.promise(() => webHandler.dispose()), - ); - - const response = yield* Effect.promise(() => - web.handler( - new Request("http://localhost/scopes/scope_1/google-discovery/sources/calendar"), - context, - ), - ); - - expect(response.status).toBe(200); - const body = yield* Effect.promise(() => response.json()); - expect(body).toMatchObject({ - namespace: "calendar", - name: "Calendar", - config: { - name: "Calendar", - service: "calendar", - version: "v3", - }, - }); - }), - ); - - it.effect("defect-returning methods produce an opaque InternalError, no leakage", () => - Effect.gen(function* () { - const web = yield* WebHandler; - const response = yield* Effect.promise(() => - web.handler( - new Request("http://localhost/scopes/scope_1/google-discovery/probe", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - discoveryUrl: "https://example.googleapis.com/$discovery/rest?version=v1", - }), - }), - HandlerContext, - ), - ); - - expect(response.status).toBe(500); - const body = yield* Effect.promise(() => response.text()); - expect(body).not.toContain("Not implemented"); - }), - ); -}); diff --git a/packages/plugins/google-discovery/src/api/handlers.ts b/packages/plugins/google-discovery/src/api/handlers.ts deleted file mode 100644 index 04450afd4..000000000 --- a/packages/plugins/google-discovery/src/api/handlers.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { HttpApiBuilder } from "effect/unstable/httpapi"; -import { Context, Effect } from "effect"; - -import { addGroup, capture } from "@executor-js/api"; -import type { GoogleDiscoveryAddSourceInput, GoogleDiscoveryPluginExtension } from "../sdk/plugin"; -import { GoogleDiscoveryStoredSourceSchema } from "../sdk/stored-source"; -import { GoogleDiscoveryGroup } from "./group"; - -// --------------------------------------------------------------------------- -// Service tag -// -// Holds the `Captured` shape — every method's `StorageFailure` channel -// has been swapped for `InternalError({ traceId })`. The host app -// provides an already-wrapped extension via -// `Layer.succeed(GoogleDiscoveryExtensionService, withCapture(executor.googleDiscovery))`. -// Handlers see `InternalError` in the error union, which matches -// `.addError(InternalError)` on the group — no per-handler translation. -// --------------------------------------------------------------------------- - -export class GoogleDiscoveryExtensionService extends Context.Service< - GoogleDiscoveryExtensionService, - GoogleDiscoveryPluginExtension ->()("GoogleDiscoveryExtensionService") {} - -// --------------------------------------------------------------------------- -// Composed API -// --------------------------------------------------------------------------- - -const ExecutorApiWithGoogleDiscovery = addGroup(GoogleDiscoveryGroup); - -// --------------------------------------------------------------------------- -// Handlers -// -// Each handler is exactly: yield the extension service, call the method, -// return. Plugin SDK errors flow through the typed channel and are -// schema-encoded to 4xx by HttpApi (see group.ts `.addError(...)` calls). -// `StorageFailure` has already been translated to `InternalError` by -// `withCapture` on the service instance; defects bubble up and are -// captured + downgraded to `InternalError(traceId)` by the API-level -// observability middleware. -// -// OAuth start/complete/callback live on the shared `/scopes/:scopeId/oauth/*` -// group in `@executor-js/api` now — the plugin has no OAuth-specific handlers. -// --------------------------------------------------------------------------- - -export const GoogleDiscoveryHandlers = HttpApiBuilder.group( - ExecutorApiWithGoogleDiscovery, - "googleDiscovery", - (handlers) => - handlers - .handle("probeDiscovery", ({ payload }) => - capture( - Effect.gen(function* () { - const ext = yield* GoogleDiscoveryExtensionService; - return yield* ext.probeDiscovery({ - discoveryUrl: payload.discoveryUrl, - credentials: payload.credentials, - }); - }), - ), - ) - .handle("addSource", ({ params: path, payload }) => - capture( - Effect.gen(function* () { - const ext = yield* GoogleDiscoveryExtensionService; - return yield* ext.addSource({ - ...(payload as Omit), - scope: path.scopeId, - }); - }), - ), - ) - .handle("getSource", ({ params: path }) => - capture( - Effect.gen(function* () { - const ext = yield* GoogleDiscoveryExtensionService; - const source = yield* ext.getSource(path.namespace, path.scopeId); - return source - ? GoogleDiscoveryStoredSourceSchema.make({ - namespace: source.namespace, - name: source.name, - config: source.config, - }) - : null; - }), - ), - ) - .handle("updateSource", ({ params: path, payload }) => - capture( - Effect.gen(function* () { - const ext = yield* GoogleDiscoveryExtensionService; - yield* ext.updateSource(path.namespace, path.scopeId, { - name: payload.name, - auth: payload.auth, - }); - return { updated: true }; - }), - ), - ), -); diff --git a/packages/plugins/google-discovery/src/api/index.ts b/packages/plugins/google-discovery/src/api/index.ts deleted file mode 100644 index c1c56fc14..000000000 --- a/packages/plugins/google-discovery/src/api/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { definePlugin } from "@executor-js/sdk/core"; - -import { googleDiscoveryPlugin } from "../sdk/plugin"; -import { GoogleDiscoveryGroup } from "./group"; -import { GoogleDiscoveryExtensionService, GoogleDiscoveryHandlers } from "./handlers"; - -export { GoogleDiscoveryGroup } from "./group"; -export { GoogleDiscoveryExtensionService, GoogleDiscoveryHandlers } from "./handlers"; - -// HTTP-augmented variant of `googleDiscoveryPlugin`. The returned -// plugin carries the HTTP `routes`, `handlers`, and `extensionService` -// so a host can mount the Google Discovery HTTP surface. Hosts that -// compose an `HttpApi` should import this. SDK-only consumers stay on -// `@executor-js/plugin-google-discovery` and never load -// `@executor-js/api`. -export const googleDiscoveryHttpPlugin = definePlugin( - (options?: Parameters[0]) => ({ - ...googleDiscoveryPlugin(options), - routes: () => GoogleDiscoveryGroup, - handlers: () => GoogleDiscoveryHandlers, - extensionService: GoogleDiscoveryExtensionService, - }), -); diff --git a/packages/plugins/google-discovery/src/promise.ts b/packages/plugins/google-discovery/src/promise.ts deleted file mode 100644 index 8a80156eb..000000000 --- a/packages/plugins/google-discovery/src/promise.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { googleDiscoveryPlugin } from "./sdk/plugin"; -export type { - GoogleDiscoveryPluginExtension, - GoogleDiscoveryAddSourceInput, - GoogleDiscoveryProbeResult, - GoogleDiscoveryUpdateSourceInput, -} from "./sdk/plugin"; diff --git a/packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx b/packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx deleted file mode 100644 index bb5f4ace3..000000000 --- a/packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx +++ /dev/null @@ -1,623 +0,0 @@ -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 * as Schema from "effect/Schema"; - -import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; -import { useScope, useUserScope } from "@executor-js/react/api/scope-context"; -import type { SecretPickerSecret } from "@executor-js/react/plugins/secret-picker"; -import { CreatableSecretPicker } from "@executor-js/react/plugins/secret-header-auth"; -import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets"; -import type { ScopeId } from "@executor-js/sdk/shared"; -import { Badge } from "@executor-js/react/components/badge"; -import { Button } from "@executor-js/react/components/button"; -import { - CardStack, - CardStackContent, - CardStackEntryField, -} from "@executor-js/react/components/card-stack"; -import { - SourceIdentityFields, - slugifyNamespace, - useSourceIdentity, -} from "@executor-js/react/plugins/source-identity"; -import { - oauthCallbackUrl, - oauthConnectionId, - useOAuthPopupFlow, -} from "@executor-js/react/plugins/oauth-sign-in"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@executor-js/react/components/collapsible"; -import { - Field, - FieldContent, - FieldDescription, - FieldGroup, - FieldLabel, - FieldLegend, - FieldSet, - FieldTitle, -} from "@executor-js/react/components/field"; -import { FilterTabs } from "@executor-js/react/components/filter-tabs"; -import { FloatActions } from "@executor-js/react/components/float-actions"; -import { Input } from "@executor-js/react/components/input"; -import { RadioGroup, RadioGroupItem } from "@executor-js/react/components/radio-group"; -import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner"; -import { addGoogleDiscoverySourceOptimistic, probeGoogleDiscovery } from "./atoms"; -import { GOOGLE_DISCOVERY_OAUTH_POPUP_NAME, googleDiscoveryOAuthStrategy } from "./oauth"; -import { googleDiscoveryPresets, type GoogleDiscoveryPreset } from "../sdk/presets"; - -const ErrorMessage = Schema.Struct({ message: Schema.String }); -const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage); - -const errorMessageFromExit = (exit: Exit.Exit, fallback: string): string => - Option.match(Option.flatMap(Exit.findErrorOption(exit), decodeErrorMessage), { - onNone: () => fallback, - onSome: ({ message }) => message, - }); - -type GoogleAuthKind = "none" | "oauth2"; - -// --------------------------------------------------------------------------- -// Client secret field with inline creation -// --------------------------------------------------------------------------- - -function SecretBackedField(props: { - label: string; - help?: string; - suggestedSecretId: string; - secretId: string | null; - onSelect: (secretId: string | null) => void; - secretList: readonly SecretPickerSecret[]; - placeholder: string; - targetScope: ScopeId; - clearable?: boolean; -}) { - const { label, help, secretId, onSelect, secretList, placeholder, clearable = true } = props; - - return ( -
-
- {label} - {help &&

{help}

} -
-
-
- onSelect(id)} - secrets={secretList} - placeholder={placeholder} - suggestedId={props.suggestedSecretId} - secretLabel={label} - targetScope={props.targetScope} - /> -
- {clearable && secretId && ( - - )} -
-
- ); -} - -type GoogleDiscoveryTemplate = GoogleDiscoveryPreset & { - readonly discoveryUrl: string; - readonly service: string; - readonly version: string; -}; - -const GOOGLE_G_ICON = "https://fonts.gstatic.com/s/i/productlogos/googleg/v6/192px.svg"; - -function parseGoogleDiscoveryPreset(preset: GoogleDiscoveryPreset): GoogleDiscoveryTemplate { - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL constructor normalizes user-provided preset URLs - 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 { - return { ...preset, discoveryUrl: preset.url, service: preset.id, version: "" }; - } -} - -const GOOGLE_DISCOVERY_TEMPLATES = googleDiscoveryPresets.map(parseGoogleDiscoveryPreset); - -const iconForService = (service: string): string | undefined => - GOOGLE_DISCOVERY_TEMPLATES.find((template) => template.service === service)?.icon; - -function GoogleServiceIcon(props: { - readonly icon?: string; - readonly service?: string; - readonly className?: string; -}) { - const { icon, service, className = "size-11" } = props; - const src = icon ?? (service ? iconForService(service) : undefined) ?? GOOGLE_G_ICON; - - return ( - - ); -} - -type ProbeOperation = { - toolPath: string; - method: string; - pathTemplate: string; - description: string | null; -}; - -type ProbeResult = { - name: string; - title: string | null; - service: string; - version: string; - toolCount: number; - scopes: readonly string[]; - operations: readonly ProbeOperation[]; -}; - -type OAuthAuth = { - kind: "oauth2"; - connectionId: string; - clientIdSecretId: string; - clientSecretSecretId: string | null; - scopes: string[]; -}; - -export default function AddGoogleDiscoverySource(props: { - readonly onComplete: () => void; - readonly onCancel: () => void; - readonly initialUrl?: string; - readonly initialPreset?: string; -}) { - const defaultTemplate = - GOOGLE_DISCOVERY_TEMPLATES.find((template) => template.id === props.initialPreset) ?? - GOOGLE_DISCOVERY_TEMPLATES.find((template) => template.id === "google-sheets") ?? - GOOGLE_DISCOVERY_TEMPLATES[0]!; - const [discoveryUrl, setDiscoveryUrl] = useState( - props.initialUrl ?? defaultTemplate.discoveryUrl, - ); - const [selectedTemplateId, setSelectedTemplateId] = useState( - props.initialUrl ? "" : defaultTemplate.id, - ); - const selectedTemplate = - GOOGLE_DISCOVERY_TEMPLATES.find((template) => template.id === selectedTemplateId) ?? null; - const [authKind, setAuthKind] = useState("oauth2"); - const [clientIdSecretId, setClientIdSecretId] = useState(null); - const [clientSecretSecretId, setClientSecretSecretId] = useState(null); - const [probe, setProbe] = useState(null); - const identity = useSourceIdentity({ - fallbackName: probe?.name ?? selectedTemplate?.name ?? "", - }); - const [oauthAuth, setOauthAuth] = useState(null); - const [loadingProbe, setLoadingProbe] = useState(false); - const [adding, setAdding] = useState(false); - const [error, setError] = useState(null); - const [showScopes, setShowScopes] = useState(false); - const resolvedNamespace = - slugifyNamespace(identity.namespace) || - slugifyNamespace(probe?.name ?? selectedTemplate?.name ?? "") || - "google"; - - const scopeId = useScope(); - const userScopeId = useUserScope(); - const doProbe = useAtomSet(probeGoogleDiscovery, { mode: "promiseExit" }); - const doAdd = useAtomSet(addGoogleDiscoverySourceOptimistic(scopeId), { - mode: "promiseExit", - }); - const secretList = useSecretPickerSecrets(); - const oauth = useOAuthPopupFlow({ - popupName: GOOGLE_DISCOVERY_OAUTH_POPUP_NAME, - popupBlockedMessage: "OAuth popup was blocked", - popupClosedMessage: "OAuth cancelled: popup was closed before completing the flow.", - startErrorMessage: "Failed to start OAuth", - }); - - const canUseOAuth = useMemo(() => (probe?.scopes.length ?? 0) > 0, [probe]); - - const applyTemplate = useCallback( - (template: GoogleDiscoveryTemplate) => { - setSelectedTemplateId(template.id); - setDiscoveryUrl(template.discoveryUrl); - identity.reset(); - setClientSecretSecretId(null); - setProbe(null); - setOauthAuth(null); - setError(null); - setShowScopes(false); - setAuthKind("oauth2"); - }, - [identity], - ); - - const handleProbe = useCallback(async () => { - setLoadingProbe(true); - setError(null); - setOauthAuth(null); - setShowScopes(false); - const exit = await doProbe({ - params: { scopeId }, - payload: { discoveryUrl: discoveryUrl.trim() }, - }); - if (Exit.isFailure(exit)) { - setProbe(null); - setLoadingProbe(false); - setError(errorMessageFromExit(exit, "Failed to inspect discovery document")); - 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 - // without depending on its identity (which changes every render). - const handleProbeRef = useRef(handleProbe); - handleProbeRef.current = handleProbe; - - // Auto-probe whenever the discovery URL changes (debounced). Clearing the - // previous probe in the onChange handler resets the preview so a new run - // will be triggered. - useEffect(() => { - const trimmed = discoveryUrl.trim(); - if (!trimmed) return; - if (probe) return; - const handle = setTimeout(() => { - handleProbeRef.current(); - }, 400); - return () => clearTimeout(handle); - }, [discoveryUrl, probe]); - - const handleStartOAuth = useCallback(async () => { - if (!probe || !clientIdSecretId) return; - setError(null); - const scopes = [...probe.scopes]; - await oauth.start({ - payload: { - endpoint: discoveryUrl.trim(), - redirectUrl: oauthCallbackUrl(), - connectionId: oauthConnectionId({ - pluginId: "google-discovery", - namespace: resolvedNamespace, - }), - tokenScope: userScopeId, - identityLabel: `${identity.name.trim() || probe.title || probe.name} OAuth`, - strategy: googleDiscoveryOAuthStrategy({ - clientIdSecretId, - clientSecretSecretId, - scopes, - }), - pluginId: "google-discovery", - }, - onSuccess: (result) => { - setOauthAuth({ - kind: "oauth2", - connectionId: result.connectionId, - clientIdSecretId, - clientSecretSecretId, - scopes, - }); - setError(null); - }, - onError: setError, - }); - }, [ - probe, - discoveryUrl, - identity.name, - clientIdSecretId, - clientSecretSecretId, - resolvedNamespace, - oauth, - userScopeId, - ]); - - const handleCancelOAuth = useCallback(() => { - oauth.cancel(); - }, [oauth]); - - const handleAdd = useCallback(async () => { - if (!probe) return; - setAdding(true); - setError(null); - const displayName = identity.name.trim() || probe.name; - const namespace = resolvedNamespace; - 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], - }); - if (Exit.isFailure(exit)) { - setError(errorMessageFromExit(exit, "Failed to add source")); - setAdding(false); - return; - } - props.onComplete(); - }, [ - probe, - doAdd, - identity, - discoveryUrl, - authKind, - oauthAuth, - props, - scopeId, - resolvedNamespace, - ]); - - const addDisabled = - !probe || adding || (authKind === "oauth2" && (!canUseOAuth || oauthAuth === null)); - - return ( -
-
-

Add Google Discovery Source

-

- Connect a Google API from its Discovery document and register its methods as tools. -

-
- - -
- Presets - Select a Google API to prefill the source. - { - const template = GOOGLE_DISCOVERY_TEMPLATES.find((t) => t.id === value); - if (template) applyTemplate(template); - }} - className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3" - > - {GOOGLE_DISCOVERY_TEMPLATES.map((template) => { - const inputId = `google-discovery-preset-${template.id}`; - return ( - - - - - {template.name} - - {template.summary} - - - - - - ); - })} - -
-
- - - - -
- { - setSelectedTemplateId(""); - setDiscoveryUrl((e.target as HTMLInputElement).value); - setProbe(null); - setOauthAuth(null); - setError(null); - }} - placeholder="https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest" - className="w-full pr-9 font-mono text-sm" - /> - {loadingProbe && ( -
- -
- )} -
-
-
-
- - - - {probe && ( -
-
-
-
- -
-
-

{probe.title ?? probe.name}

-

- {probe.service} · {probe.version} -

-
-
-
- {probe.toolCount} tools - {probe.scopes.length} scopes -
-
-
- )} - -
-
- Authentication - - tabs={[ - { value: "none", label: "None" }, - { value: "oauth2", label: "OAuth" }, - ]} - value={authKind} - onChange={setAuthKind} - /> -
- - {authKind === "oauth2" && ( -
- - - -
-
-

- {canUseOAuth - ? `${probe?.scopes.length ?? 0} scopes will be requested from Google.` - : "This API does not advertise OAuth scopes."} -

- {canUseOAuth && (probe?.scopes.length ?? 0) > 0 && ( - - - - )} -
-
- - {oauth.busy && ( - - )} -
-
- -
-
    - {(probe?.scopes ?? []).map((scope) => ( -
  • - {scope} -
  • - ))} -
-
-
-
- {oauthAuth && ( -
- Connected. Manage this connection from the Connections page. -
- )} -
- )} -
- - {error && ( -
- {error} -
- )} - - - - - -
- ); -} diff --git a/packages/plugins/google-discovery/src/react/EditGoogleDiscoverySource.tsx b/packages/plugins/google-discovery/src/react/EditGoogleDiscoverySource.tsx deleted file mode 100644 index 04c50d8ac..000000000 --- a/packages/plugins/google-discovery/src/react/EditGoogleDiscoverySource.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { useAtomValue } from "@effect/atom-react"; -import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; -import { useScope } from "@executor-js/react/api/scope-context"; -import { Badge } from "@executor-js/react/components/badge"; -import { Button } from "@executor-js/react/components/button"; - -import { googleDiscoverySourceAtom } from "./atoms"; -import GoogleDiscoverySignInButton from "./GoogleDiscoverySignInButton"; - -export default function EditGoogleDiscoverySource({ - sourceId, - onSave, -}: { - readonly sourceId: string; - readonly onSave: () => void; -}) { - const scopeId = useScope(); - const sourceResult = useAtomValue(googleDiscoverySourceAtom(scopeId, sourceId)); - - const source = AsyncResult.isSuccess(sourceResult) ? sourceResult.value : null; - const config = source?.config; - const authKind = config?.auth.kind ?? "none"; - - return ( -
-
-

Edit Google Discovery Source

-

- View configuration for this Google API source. To change authentication, remove and re-add - the source with updated OAuth credentials. -

-
- -
-
-

- {source?.name ?? sourceId} -

- {config?.discoveryUrl && ( -

- {config.discoveryUrl} -

- )} -
- - Google Discovery - -
- - {config && ( -
-
-
-

Service

-

{config.service}

-
-
-

Version

-

{config.version}

-
-
- -
-

- Authentication -

-
-

- {authKind === "oauth2" ? "OAuth 2.0" : authKind} -

- {authKind === "oauth2" && } -
-
-
- )} - -
- -
-
- ); -} diff --git a/packages/plugins/google-discovery/src/react/GoogleDiscoverySignInButton.tsx b/packages/plugins/google-discovery/src/react/GoogleDiscoverySignInButton.tsx deleted file mode 100644 index 01e971f83..000000000 --- a/packages/plugins/google-discovery/src/react/GoogleDiscoverySignInButton.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { useCallback } from "react"; -import { useAtomSet, useAtomValue } from "@effect/atom-react"; -import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; - -import { connectionsAtom } from "@executor-js/react/api/atoms"; -import { useScope, useUserScope } from "@executor-js/react/api/scope-context"; -import { connectionWriteKeys, sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; -import { - OAuthSignInButton, - oauthCallbackUrl, - useOAuthPopupFlow, - type OAuthCompletionPayload, -} from "@executor-js/react/plugins/oauth-sign-in"; - -import { googleDiscoverySourceAtom, updateGoogleDiscoverySource } from "./atoms"; -import { GOOGLE_DISCOVERY_OAUTH_POPUP_NAME, googleDiscoveryOAuthStrategy } from "./oauth"; - -// --------------------------------------------------------------------------- -// GoogleDiscoverySignInButton — top-bar action on the source detail page. -// -// Drives the shared /scopes/:scopeId/oauth/{start,callback} surface with -// a Google-specific `authorization-code` strategy. On success rewrites -// the source's auth pointer to the freshly minted connection id. Works -// whether or not the previous Connection still exists — source-owned -// OAuth config is the source of truth. -// --------------------------------------------------------------------------- - -const signInWriteKeys = [...sourceWriteKeys, ...connectionWriteKeys] as const; - -export default function GoogleDiscoverySignInButton(props: { sourceId: string }) { - const scopeId = useScope(); - const userScopeId = useUserScope(); - const sourceResult = useAtomValue(googleDiscoverySourceAtom(scopeId, props.sourceId)); - const connectionsResult = useAtomValue(connectionsAtom(scopeId)); - const doUpdate = useAtomSet(updateGoogleDiscoverySource, { mode: "promise" }); - const oauth = useOAuthPopupFlow({ - popupName: GOOGLE_DISCOVERY_OAUTH_POPUP_NAME, - popupBlockedMessage: "OAuth popup was blocked", - popupClosedMessage: "OAuth cancelled: popup was closed before completing the flow.", - }); - - const source = - AsyncResult.isSuccess(sourceResult) && sourceResult.value ? sourceResult.value : null; - const auth = source?.config.auth; - const oauth2 = auth && auth.kind === "oauth2" ? auth : null; - const connections = AsyncResult.isSuccess(connectionsResult) ? connectionsResult.value : null; - const isConnected = - oauth2 !== null && - connections !== null && - connections.some((c: { readonly id: string }) => c.id === oauth2.connectionId); - - const handleSignIn = useCallback(async () => { - if (!oauth2 || !source) return; - const scopes = [...oauth2.scopes]; - await oauth.start({ - payload: { - endpoint: source.config.discoveryUrl, - redirectUrl: oauthCallbackUrl(), - connectionId: oauth2.connectionId, - tokenScope: userScopeId, - identityLabel: `${source.name.trim() || props.sourceId} OAuth`, - strategy: googleDiscoveryOAuthStrategy({ - clientIdSecretId: oauth2.clientIdSecretId, - clientSecretSecretId: oauth2.clientSecretSecretId, - scopes, - }), - pluginId: "google-discovery", - }, - onSuccess: async (result: OAuthCompletionPayload) => { - await doUpdate({ - params: { scopeId, namespace: props.sourceId }, - payload: { - auth: { - kind: "oauth2", - connectionId: result.connectionId, - clientIdSecretId: oauth2.clientIdSecretId, - clientSecretSecretId: oauth2.clientSecretSecretId, - scopes, - }, - }, - reactivityKeys: signInWriteKeys, - }); - }, - }); - }, [oauth2, source, scopeId, props.sourceId, doUpdate, oauth, userScopeId]); - - if (!oauth2) return null; - - return ( - void handleSignIn()} - reconnectingLabel="Reconnecting…" - signingInLabel="Signing in…" - /> - ); -} diff --git a/packages/plugins/google-discovery/src/react/GoogleDiscoverySourceSummary.tsx b/packages/plugins/google-discovery/src/react/GoogleDiscoverySourceSummary.tsx deleted file mode 100644 index 872045d87..000000000 --- a/packages/plugins/google-discovery/src/react/GoogleDiscoverySourceSummary.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { Badge } from "@executor-js/react/components/badge"; - -export default function GoogleDiscoverySourceSummary({ sourceId }: { readonly sourceId: string }) { - return ( - - - Google - - {sourceId} - - ); -} diff --git a/packages/plugins/google-discovery/src/react/atoms.ts b/packages/plugins/google-discovery/src/react/atoms.ts deleted file mode 100644 index 8535aac8c..000000000 --- a/packages/plugins/google-discovery/src/react/atoms.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { ScopeId } from "@executor-js/sdk/shared"; -import * as Atom from "effect/unstable/reactivity/Atom"; -import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; -import { sourcesOptimisticAtom } from "@executor-js/react/api/atoms"; -import { ReactivityKey } from "@executor-js/react/api/reactivity-keys"; -import { GoogleDiscoveryClient } from "./client"; - -// --------------------------------------------------------------------------- -// Query atoms -// --------------------------------------------------------------------------- - -export const googleDiscoverySourceAtom = (scopeId: ScopeId, namespace: string) => - GoogleDiscoveryClient.query("googleDiscovery", "getSource", { - params: { scopeId, namespace }, - timeToLive: "15 seconds", - reactivityKeys: [ReactivityKey.sources, ReactivityKey.tools], - }); - -// --------------------------------------------------------------------------- -// Mutation atoms -// --------------------------------------------------------------------------- - -export const probeGoogleDiscovery = GoogleDiscoveryClient.mutation( - "googleDiscovery", - "probeDiscovery", -); -export const addGoogleDiscoverySource = GoogleDiscoveryClient.mutation( - "googleDiscovery", - "addSource", -); -export const addGoogleDiscoverySourceOptimistic = Atom.family((scopeId: ScopeId) => - sourcesOptimisticAtom(scopeId).pipe( - Atom.optimisticFn({ - reducer: (current, arg) => - AsyncResult.map(current, (rows) => { - const id = arg.payload.namespace ?? `pending-${Math.random().toString(36).slice(2)}`; - const source = { - id, - scopeId, - kind: "googleDiscovery", - pluginId: "google-discovery", - name: arg.payload.name, - url: arg.payload.discoveryUrl, - canRemove: false, - canRefresh: false, - canEdit: false, - runtime: false, - }; - return [source, ...rows.filter((row) => row.id !== id)].sort((a, b) => - a.name.localeCompare(b.name), - ); - }), - fn: addGoogleDiscoverySource, - }), - ), -); -export const updateGoogleDiscoverySource = GoogleDiscoveryClient.mutation( - "googleDiscovery", - "updateSource", -); -// OAuth flow atoms live on `@executor-js/react/api/atoms` now — -// `startOAuth`, `completeOAuth`, `probeOAuth`, `cancelOAuth` — one -// pair serves every OAuth-capable plugin. diff --git a/packages/plugins/google-discovery/src/react/client.ts b/packages/plugins/google-discovery/src/react/client.ts deleted file mode 100644 index 645073f86..000000000 --- a/packages/plugins/google-discovery/src/react/client.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { createPluginAtomClient } from "@executor-js/sdk/client"; -import { getBaseUrl } from "@executor-js/react/api/base-url"; -import { GoogleDiscoveryGroup } from "../api/group"; - -export const GoogleDiscoveryClient = createPluginAtomClient(GoogleDiscoveryGroup, { - baseUrl: getBaseUrl, -}); diff --git a/packages/plugins/google-discovery/src/react/index.ts b/packages/plugins/google-discovery/src/react/index.ts deleted file mode 100644 index 4db86a44b..000000000 --- a/packages/plugins/google-discovery/src/react/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { googleDiscoverySourcePlugin } from "./source-plugin"; -export { GoogleDiscoveryClient } from "./client"; -export { addGoogleDiscoverySource, probeGoogleDiscovery } from "./atoms"; diff --git a/packages/plugins/google-discovery/src/react/oauth.ts b/packages/plugins/google-discovery/src/react/oauth.ts deleted file mode 100644 index cb2a16945..000000000 --- a/packages/plugins/google-discovery/src/react/oauth.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { OAuthStrategy } from "@executor-js/sdk/shared"; - -export const GOOGLE_DISCOVERY_OAUTH_POPUP_NAME = "google-discovery-oauth"; - -const GOOGLE_AUTHORIZATION_URL = "https://accounts.google.com/o/oauth2/v2/auth"; -const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"; - -const GOOGLE_EXTRA_AUTHORIZATION_PARAMS = { - access_type: "offline", - include_granted_scopes: "true", - prompt: "consent", -} as const; - -export const googleDiscoveryOAuthStrategy = (input: { - readonly clientIdSecretId: string; - readonly clientSecretSecretId: string | null; - readonly scopes: readonly string[]; -}): OAuthStrategy => ({ - kind: "authorization-code", - authorizationEndpoint: GOOGLE_AUTHORIZATION_URL, - tokenEndpoint: GOOGLE_TOKEN_URL, - issuerUrl: "https://accounts.google.com", - clientIdSecretId: input.clientIdSecretId, - clientSecretSecretId: input.clientSecretSecretId, - scopes: [...input.scopes], - extraAuthorizationParams: GOOGLE_EXTRA_AUTHORIZATION_PARAMS, -}); diff --git a/packages/plugins/google-discovery/src/react/plugin-client.tsx b/packages/plugins/google-discovery/src/react/plugin-client.tsx deleted file mode 100644 index 685237406..000000000 --- a/packages/plugins/google-discovery/src/react/plugin-client.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import { defineClientPlugin } from "@executor-js/sdk/client"; - -import { googleDiscoverySourcePlugin } from "./source-plugin"; - -export default defineClientPlugin({ - id: "googleDiscovery" as const, - sourcePlugin: googleDiscoverySourcePlugin, -}); diff --git a/packages/plugins/google-discovery/src/react/source-plugin.ts b/packages/plugins/google-discovery/src/react/source-plugin.ts deleted file mode 100644 index 6f0ace74e..000000000 --- a/packages/plugins/google-discovery/src/react/source-plugin.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { lazy } from "react"; -import type { SourcePlugin } from "@executor-js/sdk/client"; -import { googleDiscoveryPresets } from "../sdk/presets"; - -const importAdd = () => import("./AddGoogleDiscoverySource"); -const importEdit = () => import("./EditGoogleDiscoverySource"); -const importSummary = () => import("./GoogleDiscoverySourceSummary"); - -export const googleDiscoverySourcePlugin: SourcePlugin = { - key: "googleDiscovery", - label: "Google Discovery", - add: lazy(importAdd), - edit: lazy(importEdit), - summary: lazy(importSummary), - presets: googleDiscoveryPresets, - preload: () => { - void importAdd(); - void importEdit(); - void importSummary(); - }, -}; diff --git a/packages/plugins/google-discovery/src/sdk/binding-store.ts b/packages/plugins/google-discovery/src/sdk/binding-store.ts deleted file mode 100644 index f9e69d27b..000000000 --- a/packages/plugins/google-discovery/src/sdk/binding-store.ts +++ /dev/null @@ -1,693 +0,0 @@ -// --------------------------------------------------------------------------- -// Google Discovery plugin store over FumaDB. Operates on two primary tables: -// -// google_discovery_source — per-namespace source config blob -// google_discovery_binding — per-tool-id method binding -// -// OAuth session storage lives at the core level in `oauth2_session` and -// is owned by `ctx.oauth`. -// -// All JSON columns are round-tripped via Schema.encode/decode so `Option` -// shapes inside GoogleDiscoveryStoredSourceData / GoogleDiscoveryMethodBinding -// survive storage serialization. -// --------------------------------------------------------------------------- - -import { Effect, Option, Schema } from "effect"; - -import { - dateColumn, - type FumaTables, - jsonColumn, - nullableTextColumn, - scopedExecutorTable, - type StorageDeps, - type StorageFailure, - textColumn, -} from "@executor-js/sdk/core"; - -import { - GoogleDiscoveryMethodBinding, - GoogleDiscoveryStoredSourceData, - type GoogleDiscoveryAuth, - type GoogleDiscoveryCredentialValue, - type GoogleDiscoveryFetchCredentials, -} from "./types"; - -// --------------------------------------------------------------------------- -// OAuth session TTL -// --------------------------------------------------------------------------- - -export const GOOGLE_DISCOVERY_OAUTH_SESSION_TTL_MS = 15 * 60 * 1000; - -// --------------------------------------------------------------------------- -// Schema — plugin-declared tables merged with coreSchema at executor start. -// --------------------------------------------------------------------------- - -export const googleDiscoverySchema = { - google_discovery_source: scopedExecutorTable("google_discovery_source", { - name: textColumn("name"), - // Plugin-private structural config minus auth/credentials — - // discoveryUrl, service, version, rootUrl, servicePath. These - // never carry refs. - config: jsonColumn("config"), - auth_kind: textColumn("auth_kind").defaultTo("none"), - auth_connection_id: nullableTextColumn("auth_connection_id"), - auth_client_id_secret_id: nullableTextColumn("auth_client_id_secret_id"), - auth_client_secret_secret_id: nullableTextColumn("auth_client_secret_secret_id"), - // Stored as JSON because it is a string[] and carries no refs. - auth_scopes: jsonColumn("auth_scopes").nullable(), - created_at: dateColumn("created_at"), - updated_at: dateColumn("updated_at"), - }), - google_discovery_source_credential_header: scopedExecutorTable( - "google_discovery_source_credential_header", - { - source_id: textColumn("source_id"), - name: textColumn("name"), - kind: textColumn("kind"), - text_value: nullableTextColumn("text_value"), - secret_id: nullableTextColumn("secret_id"), - secret_prefix: nullableTextColumn("secret_prefix"), - }, - ), - google_discovery_source_credential_query_param: scopedExecutorTable( - "google_discovery_source_credential_query_param", - { - source_id: textColumn("source_id"), - name: textColumn("name"), - kind: textColumn("kind"), - text_value: nullableTextColumn("text_value"), - secret_id: nullableTextColumn("secret_id"), - secret_prefix: nullableTextColumn("secret_prefix"), - }, - ), - google_discovery_binding: scopedExecutorTable("google_discovery_binding", { - source_id: textColumn("source_id"), - binding: jsonColumn("binding"), - created_at: dateColumn("created_at"), - }), -} satisfies FumaTables; - -export type GoogleDiscoverySchema = typeof googleDiscoverySchema; - -// --------------------------------------------------------------------------- -// Stored source projection for the extension API. -// --------------------------------------------------------------------------- - -export interface GoogleDiscoveryStoredSource { - readonly namespace: string; - /** Executor scope id this source row lives in. Writes stamp this on - * `scope_id`; reads choose scope explicitly in the FumaDB query. */ - readonly scope: string; - readonly name: string; - readonly config: GoogleDiscoveryStoredSourceData; -} - -// --------------------------------------------------------------------------- -// Schema encode/decode for JSON columns so Option round-trips properly. -// --------------------------------------------------------------------------- - -const encodeStoredSourceData = Schema.encodeSync(GoogleDiscoveryStoredSourceData); -const decodeStoredSourceData = Schema.decodeUnknownSync(GoogleDiscoveryStoredSourceData); - -const encodeBinding = Schema.encodeSync(GoogleDiscoveryMethodBinding); -const decodeBinding = Schema.decodeUnknownSync(GoogleDiscoveryMethodBinding); - -const toJsonRecord = (value: unknown): Record => value as Record; -const decodeString = Schema.decodeUnknownSync(Schema.String); -const decodeJsonObject = Schema.decodeUnknownSync(Schema.Record(Schema.String, Schema.Unknown)); -const decodeJsonString = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); - -const decodeJson = (value: unknown): unknown => { - if (value === null || value === undefined) return value; - if (typeof value !== "string") return value; - return Option.getOrElse(decodeJsonString(value), () => value); -}; - -// --- auth column packing/unpacking ------------------------------------------ - -interface AuthColumns { - readonly auth_kind: "none" | "oauth2"; - readonly auth_connection_id: string | null; - readonly auth_client_id_secret_id: string | null; - readonly auth_client_secret_secret_id: string | null; - readonly auth_scopes: string[]; -} - -const authToColumns = (auth: GoogleDiscoveryAuth): AuthColumns => { - if (auth.kind === "oauth2") { - return { - auth_kind: "oauth2", - auth_connection_id: auth.connectionId, - auth_client_id_secret_id: auth.clientIdSecretId, - auth_client_secret_secret_id: auth.clientSecretSecretId ?? null, - auth_scopes: [...auth.scopes], - }; - } - return { - auth_kind: "none", - auth_connection_id: null, - auth_client_id_secret_id: null, - auth_client_secret_secret_id: null, - auth_scopes: [], - }; -}; - -const columnsToAuth = (row: Record): GoogleDiscoveryAuth => { - if ( - row.auth_kind === "oauth2" && - typeof row.auth_connection_id === "string" && - typeof row.auth_client_id_secret_id === "string" - ) { - const csec = row.auth_client_secret_secret_id as string | null | undefined; - const rawScopes = decodeJson(row.auth_scopes); - const scopes = Array.isArray(rawScopes) - ? rawScopes.filter((item): item is string => typeof item === "string") - : []; - return { - kind: "oauth2", - connectionId: row.auth_connection_id, - clientIdSecretId: row.auth_client_id_secret_id, - clientSecretSecretId: csec ?? null, - scopes: [...scopes], - }; - } - return { kind: "none" }; -}; - -// --- SecretBackedValue maps <-> child rows ---------------------------------- - -interface CredentialRow { - readonly id: string; - readonly scope_id: string; - readonly source_id: string; - readonly name: string; - readonly kind: "text" | "secret"; - readonly text_value?: string; - readonly secret_id?: string; - readonly secret_prefix?: string; - readonly [k: string]: unknown; -} - -const valueMapToRows = ( - sourceId: string, - scope: string, - values: Record | undefined, -): readonly CredentialRow[] => { - if (!values) return []; - return Object.entries(values).map(([name, value]) => { - const id = JSON.stringify([sourceId, name]); - if (typeof value === "string") { - return { - id, - scope_id: scope, - source_id: sourceId, - name, - kind: "text", - text_value: value, - }; - } - return { - id, - scope_id: scope, - source_id: sourceId, - name, - kind: "secret", - secret_id: value.secretId, - secret_prefix: value.prefix, - }; - }); -}; - -const rowsToValueMap = ( - rows: readonly Record[], -): Record => { - const out: Record = {}; - for (const row of rows) { - const name = decodeString(row.name); - if (row.kind === "secret" && typeof row.secret_id === "string") { - const prefix = row.secret_prefix as string | undefined | null; - out[name] = prefix ? { secretId: row.secret_id, prefix } : { secretId: row.secret_id }; - } else if (row.kind === "text" && typeof row.text_value === "string") { - out[name] = row.text_value; - } - } - return out; -}; - -// --------------------------------------------------------------------------- -// Store interface -// --------------------------------------------------------------------------- - -// Every read/write that targets a single keyed row pins BOTH the natural -// id (toolId, sourceId, sessionId) AND the owning `scope_id`. Scope is a -// normal FumaDB predicate here, not hidden behavior. -export interface GoogleDiscoveryStore { - readonly getBinding: ( - toolId: string, - scope: string, - ) => Effect.Effect< - { readonly namespace: string; readonly binding: GoogleDiscoveryMethodBinding } | null, - StorageFailure - >; - readonly putBinding: ( - toolId: string, - sourceId: string, - scope: string, - binding: GoogleDiscoveryMethodBinding, - ) => Effect.Effect; - readonly removeBindingsBySource: ( - sourceId: string, - scope: string, - ) => Effect.Effect; - readonly getBindingsForSource: ( - sourceId: string, - scope: string, - ) => Effect.Effect, StorageFailure>; - - readonly putSource: (source: GoogleDiscoveryStoredSource) => Effect.Effect; - readonly updateSourceMeta: ( - sourceId: string, - scope: string, - update: { - readonly name?: string; - readonly auth?: import("./types").GoogleDiscoveryAuth; - }, - ) => Effect.Effect; - readonly removeSource: (sourceId: string, scope: string) => Effect.Effect; - readonly getSource: ( - sourceId: string, - scope: string, - ) => Effect.Effect; - readonly getSourceConfig: ( - sourceId: string, - scope: string, - ) => Effect.Effect; - - // --------------------------------------------------------------------- - // Usage lookups — back `usagesForSecret` / `usagesForConnection`. - // --------------------------------------------------------------------- - - /** Source rows whose oauth2 auth columns reference the given secret id. - * `slot` distinguishes client_id vs client_secret. */ - readonly findSourcesBySecret: (secretId: string) => Effect.Effect< - readonly { - readonly namespace: string; - readonly scope_id: string; - readonly name: string; - readonly slot: string; - }[], - StorageFailure - >; - - /** Source rows whose oauth2 auth points at the given connection id. */ - readonly findSourcesByConnection: (connectionId: string) => Effect.Effect< - readonly { - readonly namespace: string; - readonly scope_id: string; - readonly name: string; - readonly slot: string; - }[], - StorageFailure - >; - - /** Credential header / query_param child rows referencing the secret. */ - readonly findCredentialRowsBySecret: (secretId: string) => Effect.Effect< - readonly { - readonly kind: "credential_header" | "credential_query_param"; - readonly source_id: string; - readonly scope_id: string; - readonly name: string; - }[], - StorageFailure - >; - - readonly lookupSourceNames: ( - keys: readonly string[], - ) => Effect.Effect, StorageFailure>; -} - -// --------------------------------------------------------------------------- -// Default store -// --------------------------------------------------------------------------- - -export const makeGoogleDiscoveryStore = ( - deps: StorageDeps, -): GoogleDiscoveryStore => { - const { fuma } = deps; - - return { - getBinding: (toolId, scope) => - Effect.gen(function* () { - const row = yield* fuma.use("google_discovery_binding.findFirstByScopedId", (db) => - db.findFirst("google_discovery_binding", { - where: (b) => b.and(b("id", "=", toolId), b("scope_id", "=", scope)), - }), - ); - if (!row) return null; - const decoded = decodeBinding(decodeJson(row.binding)); - return { namespace: decodeString(row.source_id), binding: decoded }; - }), - - putBinding: (toolId, sourceId, scope, binding) => - Effect.gen(function* () { - yield* fuma.use("google_discovery_binding.deleteManyByScopedId", (db) => - db.deleteMany("google_discovery_binding", { - where: (b) => b.and(b("id", "=", toolId), b("scope_id", "=", scope)), - }), - ); - yield* fuma.use("google_discovery_binding.create", (db) => - db.create("google_discovery_binding", { - id: toolId, - scope_id: scope, - source_id: sourceId, - binding: toJsonRecord(encodeBinding(binding)), - created_at: new Date(), - }), - ); - }), - - removeBindingsBySource: (sourceId, scope) => - Effect.gen(function* () { - const rows = yield* fuma.use("google_discovery_binding.findManyBySourceScope", (db) => - db.findMany("google_discovery_binding", { - where: (b) => b.and(b("source_id", "=", sourceId), b("scope_id", "=", scope)), - }), - ); - const ids = rows.map((r) => decodeString(r.id)); - yield* fuma.use("google_discovery_binding.deleteManyBySourceScope", (db) => - db.deleteMany("google_discovery_binding", { - where: (b) => b.and(b("source_id", "=", sourceId), b("scope_id", "=", scope)), - }), - ); - return ids; - }), - - getBindingsForSource: (sourceId, scope) => - Effect.gen(function* () { - const rows = yield* fuma.use("google_discovery_binding.findManyBySourceScope", (db) => - db.findMany("google_discovery_binding", { - where: (b) => b.and(b("source_id", "=", sourceId), b("scope_id", "=", scope)), - }), - ); - const out = new Map(); - for (const row of rows) { - out.set(decodeString(row.id), decodeBinding(decodeJson(row.binding))); - } - return out; - }), - - putSource: (source) => - Effect.gen(function* () { - const now = new Date(); - // Wipe the source row + every child row before recreating — - // matches putSource's "fully replace" semantic. - yield* fuma.use("google_discovery_source.deleteManyByScopedId", (db) => - db.deleteMany("google_discovery_source", { - where: (b) => b.and(b("id", "=", source.namespace), b("scope_id", "=", source.scope)), - }), - ); - yield* deleteSourceChildren(source.namespace, source.scope); - - const encoded = stripExtractedFields( - decodeJsonObject(encodeStoredSourceData(source.config)), - ); - yield* fuma.use("google_discovery_source.create", (db) => - db.create("google_discovery_source", { - id: source.namespace, - scope_id: source.scope, - name: source.name, - config: toJsonRecord(encoded), - created_at: now, - updated_at: now, - ...authToColumns(source.config.auth), - }), - ); - yield* writeCredentialRows(source.namespace, source.scope, source.config.credentials); - }), - - updateSourceMeta: (sourceId, scope, update) => - Effect.gen(function* () { - const row = yield* fuma.use("google_discovery_source.findFirstByScopedId", (db) => - db.findFirst("google_discovery_source", { - where: (b) => b.and(b("id", "=", sourceId), b("scope_id", "=", scope)), - }), - ); - if (!row) return; - const auth = update.auth ?? columnsToAuth(row); - yield* fuma.use("google_discovery_source.updateManyByScopedId", (db) => - db.updateMany("google_discovery_source", { - where: (b) => b.and(b("id", "=", sourceId), b("scope_id", "=", scope)), - set: { - name: update.name ?? decodeString(row.name), - updated_at: new Date(), - ...authToColumns(auth), - }, - }), - ); - }), - - removeSource: (sourceId, scope) => - Effect.gen(function* () { - yield* deleteSourceChildren(sourceId, scope); - yield* fuma.use("google_discovery_source.deleteManyByScopedId", (db) => - db.deleteMany("google_discovery_source", { - where: (b) => b.and(b("id", "=", sourceId), b("scope_id", "=", scope)), - }), - ); - }), - - getSource: (sourceId, scope) => - Effect.gen(function* () { - const row = yield* fuma.use("google_discovery_source.findFirstByScopedId", (db) => - db.findFirst("google_discovery_source", { - where: (b) => b.and(b("id", "=", sourceId), b("scope_id", "=", scope)), - }), - ); - if (!row) return null; - return { - namespace: decodeString(row.id), - scope: decodeString(row.scope_id), - name: decodeString(row.name), - config: yield* hydrateStoredSourceData(row, sourceId, scope), - }; - }), - - getSourceConfig: (sourceId, scope) => - Effect.gen(function* () { - const row = yield* fuma.use("google_discovery_source.findFirstByScopedId", (db) => - db.findFirst("google_discovery_source", { - where: (b) => b.and(b("id", "=", sourceId), b("scope_id", "=", scope)), - }), - ); - if (!row) return null; - return yield* hydrateStoredSourceData(row, sourceId, scope); - }), - - findSourcesBySecret: (secretId) => - Effect.gen(function* () { - const [byClientId, byClientSecret] = yield* Effect.all( - [ - fuma.use("google_discovery_source.findManyByClientIdSecret", (db) => - db.findMany("google_discovery_source", { - where: (b) => b("auth_client_id_secret_id", "=", secretId), - }), - ), - fuma.use("google_discovery_source.findManyByClientSecretSecret", (db) => - db.findMany("google_discovery_source", { - where: (b) => b("auth_client_secret_secret_id", "=", secretId), - }), - ), - ], - { concurrency: "unbounded" }, - ); - const out: { - readonly namespace: string; - readonly scope_id: string; - readonly name: string; - readonly slot: string; - }[] = []; - for (const r of byClientId) { - out.push({ - namespace: decodeString(r.id), - scope_id: decodeString(r.scope_id), - name: decodeString(r.name), - slot: "auth.oauth2.client_id", - }); - } - for (const r of byClientSecret) { - out.push({ - namespace: decodeString(r.id), - scope_id: decodeString(r.scope_id), - name: decodeString(r.name), - slot: "auth.oauth2.client_secret", - }); - } - return out; - }), - - findSourcesByConnection: (connectionId) => - fuma - .use("google_discovery_source.findManyByConnection", (db) => - db.findMany("google_discovery_source", { - where: (b) => b("auth_connection_id", "=", connectionId), - }), - ) - .pipe( - Effect.map((rows) => - rows.map((r) => ({ - namespace: decodeString(r.id), - scope_id: decodeString(r.scope_id), - name: decodeString(r.name), - slot: "auth.oauth2.connection", - })), - ), - ), - - findCredentialRowsBySecret: (secretId) => - Effect.gen(function* () { - const [headers, params] = yield* Effect.all( - [ - fuma.use("google_discovery_source_credential_header.findManyBySecret", (db) => - db.findMany("google_discovery_source_credential_header", { - where: (b) => b("secret_id", "=", secretId), - }), - ), - fuma.use("google_discovery_source_credential_query_param.findManyBySecret", (db) => - db.findMany("google_discovery_source_credential_query_param", { - where: (b) => b("secret_id", "=", secretId), - }), - ), - ], - { concurrency: "unbounded" }, - ); - return [ - ...headers.map((r) => ({ - kind: "credential_header" as const, - source_id: decodeString(r.source_id), - scope_id: decodeString(r.scope_id), - name: decodeString(r.name), - })), - ...params.map((r) => ({ - kind: "credential_query_param" as const, - source_id: decodeString(r.source_id), - scope_id: decodeString(r.scope_id), - name: decodeString(r.name), - })), - ]; - }), - - lookupSourceNames: (keys) => - Effect.gen(function* () { - if (keys.length === 0) return new Map(); - const rows = yield* fuma.use("google_discovery_source.findMany", (db) => - db.findMany("google_discovery_source"), - ); - const requested = new Set(keys); - const out = new Map(); - for (const r of rows) { - const key = `${decodeString(r.scope_id)}:${decodeString(r.id)}`; - if (requested.has(key)) out.set(key, decodeString(r.name)); - } - return out; - }), - }; - - // --------------------------------------------------------------------- - // Closure helpers (depend on `fuma`). - // --------------------------------------------------------------------- - - function deleteSourceChildren(sourceId: string, scope: string) { - // Drop only credential child rows. Bindings live independently and - // are managed via putBinding / removeBindingsBySource — wiping them - // here would break putSource (which legitimately keeps existing - // bindings) and the test for "registers and invokes ... tools". - return Effect.gen(function* () { - for (const model of [ - "google_discovery_source_credential_header", - "google_discovery_source_credential_query_param", - ] as const) { - yield* fuma.use(`${model}.deleteManyBySourceScope`, (db) => - db.deleteMany(model, { - where: (b) => b.and(b("source_id", "=", sourceId), b("scope_id", "=", scope)), - }), - ); - } - }); - } - - function writeCredentialRows( - sourceId: string, - scope: string, - credentials: GoogleDiscoveryFetchCredentials | undefined, - ) { - return Effect.gen(function* () { - if (!credentials) return; - const headerRows = valueMapToRows(sourceId, scope, credentials.headers); - if (headerRows.length > 0) { - yield* fuma - .use("google_discovery_source_credential_header.createMany", (db) => - db.createMany("google_discovery_source_credential_header", [...headerRows]), - ) - .pipe(Effect.asVoid); - } - const paramRows = valueMapToRows(sourceId, scope, credentials.queryParams); - if (paramRows.length > 0) { - yield* fuma - .use("google_discovery_source_credential_query_param.createMany", (db) => - db.createMany("google_discovery_source_credential_query_param", [...paramRows]), - ) - .pipe(Effect.asVoid); - } - }); - } - - function hydrateStoredSourceData( - row: Record, - sourceId: string, - scope: string, - ): Effect.Effect { - return Effect.gen(function* () { - const partial = decodeJsonObject(decodeJson(row.config)); - const headerRows = yield* fuma.use( - "google_discovery_source_credential_header.findManyBySourceScope", - (db) => - db.findMany("google_discovery_source_credential_header", { - where: (b) => b.and(b("source_id", "=", sourceId), b("scope_id", "=", scope)), - }), - ); - const paramRows = yield* fuma.use( - "google_discovery_source_credential_query_param.findManyBySourceScope", - (db) => - db.findMany("google_discovery_source_credential_query_param", { - where: (b) => b.and(b("source_id", "=", sourceId), b("scope_id", "=", scope)), - }), - ); - const headers = rowsToValueMap(headerRows); - const queryParams = rowsToValueMap(paramRows); - const credentials = - Object.keys(headers).length === 0 && Object.keys(queryParams).length === 0 - ? undefined - : { - ...(Object.keys(headers).length > 0 ? { headers } : {}), - ...(Object.keys(queryParams).length > 0 ? { queryParams } : {}), - }; - const reassembled = { - ...partial, - auth: columnsToAuth(row), - ...(credentials ? { credentials } : {}), - }; - return decodeStoredSourceData(reassembled); - }); - } -}; - -// Strip auth/credentials from the encoded source-data shape. Those -// moved to columns and child tables; the remaining structural fields -// live in the `config` JSON. -const stripExtractedFields = (encoded: Record): Record => { - const { auth, credentials, ...rest } = encoded; - void auth; - void credentials; - return rest; -}; diff --git a/packages/plugins/google-discovery/src/sdk/document.test.ts b/packages/plugins/google-discovery/src/sdk/document.test.ts deleted file mode 100644 index 1b3c7fb89..000000000 --- a/packages/plugins/google-discovery/src/sdk/document.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { readFileSync } from "node:fs"; -import { resolve } from "node:path"; - -import { describe, expect, it } from "@effect/vitest"; -import { Effect, Option } from "effect"; - -import { extractGoogleDiscoveryManifest } from "./document"; - -const fixturePath = resolve(__dirname, "../../fixtures/drive.json"); -const fixtureText = readFileSync(fixturePath, "utf8"); - -describe("Google Discovery document", () => { - it("extracts methods, refs, and oauth scopes", async () => { - const manifest = await Effect.runPromise(extractGoogleDiscoveryManifest(fixtureText)); - - expect(Option.getOrElse(manifest.title, () => "")).toBe("Google Drive"); - expect(manifest.service).toBe("drive"); - expect(manifest.version).toBe("v3"); - expect(manifest.methods.map((method) => method.toolPath)).toEqual([ - "files.get", - "files.update", - ]); - expect(Object.keys(manifest.schemaDefinitions)).toEqual(["File", "UpdateFileRequest"]); - - const getFile = manifest.methods.find((method) => method.toolPath === "files.get"); - expect(getFile).toBeDefined(); - expect(getFile!.binding.pathTemplate).toBe("files/{fileId}"); - expect(Option.isSome(getFile!.inputSchema)).toBe(true); - expect(Option.isSome(getFile!.outputSchema)).toBe(true); - - const inputSchema = Option.getOrThrow(getFile!.inputSchema) as Record; - const properties = inputSchema.properties as Record; - expect(properties.fileId).toMatchObject({ type: "string" }); - expect(properties.prettyPrint).toMatchObject({ type: "boolean" }); - - const updateFile = manifest.methods.find((method) => method.toolPath === "files.update"); - expect(updateFile).toBeDefined(); - const updateInputSchema = Option.getOrThrow(updateFile!.inputSchema) as Record; - expect(updateInputSchema.properties).toHaveProperty("body"); - - const scopes = Option.getOrElse(manifest.oauthScopes, () => ({})); - expect(Object.keys(scopes)).toContain("https://www.googleapis.com/auth/drive"); - expect(Object.keys(scopes)).toContain("https://www.googleapis.com/auth/drive.readonly"); - }); -}); diff --git a/packages/plugins/google-discovery/src/sdk/document.ts b/packages/plugins/google-discovery/src/sdk/document.ts deleted file mode 100644 index e5859acaf..000000000 --- a/packages/plugins/google-discovery/src/sdk/document.ts +++ /dev/null @@ -1,530 +0,0 @@ -import { Effect, Option, Schema, SchemaGetter } from "effect"; - -import { GoogleDiscoveryParseError } from "./errors"; -import { - GoogleDiscoveryHttpMethod, - GoogleDiscoveryManifest, - GoogleDiscoveryManifestMethod, - GoogleDiscoveryMethodBinding, - GoogleDiscoveryParameter, - GoogleDiscoveryParameterLocation, -} from "./types"; - -type JsonObject = Record; - -const TrimmedString = Schema.Trim; -const Text = Schema.Trim.pipe(Schema.decodeTo(Schema.NonEmptyString)); -const LowercaseText = Schema.String.pipe( - Schema.decode({ - decode: SchemaGetter.transform((value) => value.trim().toLowerCase()), - encode: SchemaGetter.transform((value) => value.trim().toLowerCase()), - }), -); -const TextOption = Schema.OptionFromOptional(TrimmedString).pipe( - Schema.decode({ - decode: SchemaGetter.transform((value) => Option.filter(value, (text) => text.length > 0)), - encode: SchemaGetter.transform((value) => value), - }), - Schema.withDecodingDefaultType(Effect.succeed(Option.none())), -); -const TextArray = Schema.optional(Schema.Array(Text)).pipe( - Schema.withDecodingDefaultType(Effect.succeed([] as string[])), -); -const UnknownRecord = Schema.Record(Schema.String, Schema.Unknown); -const UnknownRecordWithDefault = Schema.optional(UnknownRecord).pipe( - Schema.withDecodingDefaultType(Effect.succeed({})), -); - -const DiscoveryHttpMethodInput = Schema.optional( - LowercaseText.pipe(Schema.decodeTo(GoogleDiscoveryHttpMethod)), -); - -const DiscoveryParameterLocationInput = Schema.optional( - LowercaseText.pipe(Schema.decodeTo(GoogleDiscoveryParameterLocation)), -); - -const DiscoveryDefaultValue = Schema.Union([Schema.String, Schema.Number, Schema.Boolean]); - -const DiscoverySchemaModel = Schema.Struct({ - type: Schema.optional(LowercaseText), - description: TextOption, - properties: UnknownRecordWithDefault, - items: Schema.optional(Schema.Unknown), - additionalProperties: Schema.optional(Schema.Union([Schema.Boolean, Schema.Unknown])), - enum: TextArray, - format: Schema.optional(Text), - readOnly: Schema.optional(Schema.Boolean), - default: Schema.optional(DiscoveryDefaultValue), - $ref: Schema.optional(Text), - required: TextArray, -}); -type DiscoverySchema = typeof DiscoverySchemaModel.Type; - -const DiscoveryParameterModel = Schema.Struct({ - type: Schema.optional(LowercaseText), - description: TextOption, - properties: UnknownRecordWithDefault, - items: Schema.optional(Schema.Unknown), - additionalProperties: Schema.optional(Schema.Union([Schema.Boolean, Schema.Unknown])), - enum: TextArray, - format: Schema.optional(Text), - readOnly: Schema.optional(Schema.Boolean), - default: Schema.optional(DiscoveryDefaultValue), - $ref: Schema.optional(Text), - location: DiscoveryParameterLocationInput, - required: Schema.optional(Schema.Boolean), - repeated: Schema.optional(Schema.Boolean), -}); -type DiscoveryParameter = typeof DiscoveryParameterModel.Type; - -const DiscoveryRefModel = Schema.Struct({ - $ref: Schema.optional(Text), -}); - -const DiscoveryMethodModel = Schema.Struct({ - id: TextOption, - description: TextOption, - httpMethod: DiscoveryHttpMethodInput, - path: TextOption, - parameters: UnknownRecordWithDefault, - request: Schema.optional(DiscoveryRefModel), - response: Schema.optional(DiscoveryRefModel), - scopes: TextArray, -}); -type DiscoveryMethod = typeof DiscoveryMethodModel.Type; - -const DiscoveryResourceModel = Schema.Struct({ - methods: UnknownRecordWithDefault, - resources: UnknownRecordWithDefault, -}); - -const DiscoveryDocumentModel = Schema.Struct({ - name: TextOption, - version: TextOption, - title: TextOption, - rootUrl: TextOption, - servicePath: Schema.optional(TrimmedString).pipe( - Schema.withDecodingDefaultType(Effect.succeed("")), - ), - parameters: UnknownRecordWithDefault, - methods: UnknownRecordWithDefault, - resources: UnknownRecordWithDefault, - schemas: UnknownRecordWithDefault, - auth: Schema.optional( - Schema.Struct({ - oauth2: Schema.optional( - Schema.Struct({ - scopes: Schema.optional( - Schema.Record( - Schema.String, - Schema.Struct({ - description: TextOption, - }), - ), - ).pipe(Schema.withDecodingDefaultType(Effect.succeed({}))), - }), - ), - }), - ), -}); -type DiscoveryDocument = typeof DiscoveryDocumentModel.Type; - -const decodeUnknownWith = -
( - message: string, - decode: (value: unknown) => A, - ): ((value: unknown) => Effect.Effect) => - (value) => - Effect.try({ - try: () => decode(value), - // The Schema.TaggedError version of GoogleDiscoveryParseError no - // longer carries a `cause` field because the client only sees the - // user-facing message. - catch: () => new GoogleDiscoveryParseError({ message }), - }); - -const decodeDiscoveryDocument = decodeUnknownWith( - "Failed to decode Google Discovery document", - Schema.decodeUnknownSync(DiscoveryDocumentModel), -); - -const decodeDiscoveryDocumentJson = decodeUnknownWith( - "Failed to parse Google Discovery document", - Schema.decodeUnknownSync(Schema.fromJsonString(DiscoveryDocumentModel)), -); - -const decodeDiscoverySchema = decodeUnknownWith( - "Failed to decode Google Discovery schema", - Schema.decodeUnknownSync(DiscoverySchemaModel), -); - -const decodeDiscoveryParameter = decodeUnknownWith( - "Failed to decode Google Discovery parameter", - Schema.decodeUnknownSync(DiscoveryParameterModel), -); - -const decodeDiscoveryMethod = decodeUnknownWith( - "Failed to decode Google Discovery method", - Schema.decodeUnknownSync(DiscoveryMethodModel), -); - -const decodeDiscoveryResource = decodeUnknownWith( - "Failed to decode Google Discovery resource", - Schema.decodeUnknownSync(DiscoveryResourceModel), -); - -const schemaRef = (name: string) => `#/$defs/${name}`; - -const toJsonSchemaSeed = (input: { - type: DiscoverySchema["type"]; - description: DiscoverySchema["description"]; - properties: DiscoverySchema["properties"]; - items: DiscoverySchema["items"]; - additionalProperties: DiscoverySchema["additionalProperties"]; - enum: DiscoverySchema["enum"]; - format: DiscoverySchema["format"]; - readOnly: DiscoverySchema["readOnly"]; - default: DiscoverySchema["default"]; - $ref: DiscoverySchema["$ref"]; - required: DiscoverySchema["required"]; -}): DiscoverySchema => ({ - type: input.type, - description: input.description, - properties: input.properties ?? {}, - items: input.items, - additionalProperties: input.additionalProperties, - enum: input.enum ?? [], - format: input.format, - readOnly: input.readOnly, - default: input.default, - $ref: input.$ref, - required: input.required ?? [], -}); - -const discoverySchemaToJsonSchema = ( - schema: DiscoverySchema, -): Effect.Effect => - Effect.gen(function* () { - const ref = schema.$ref; - if (ref) { - return { $ref: schemaRef(ref) }; - } - - const description = Option.getOrUndefined(schema.description); - const enumValues = schema.enum ?? []; - const required = schema.required ?? []; - const base: Record = { - ...(description ? { description } : {}), - ...(schema.format ? { format: schema.format } : {}), - ...(enumValues.length > 0 ? { enum: enumValues } : {}), - ...(schema.readOnly === true ? { readOnly: true } : {}), - ...(schema.default !== undefined ? { default: schema.default } : {}), - }; - - if (schema.type === "array") { - return { - ...base, - type: "array", - items: yield* googleSchemaToJsonSchema(schema.items), - }; - } - - const properties = schema.properties ?? {}; - const additionalProperties = schema.additionalProperties; - if ( - schema.type === "object" || - Object.keys(properties).length > 0 || - additionalProperties !== undefined - ) { - const convertedProperties = Object.fromEntries( - yield* Effect.forEach(Object.entries(properties), ([name, property]) => - googleSchemaToJsonSchema(property).pipe( - Effect.map((jsonSchema) => [name, jsonSchema] as const), - ), - ), - ); - - const convertedAdditionalProperties = - additionalProperties === undefined - ? undefined - : additionalProperties === true - ? true - : yield* googleSchemaToJsonSchema(additionalProperties); - - return { - ...base, - type: "object", - ...(Object.keys(convertedProperties).length > 0 ? { properties: convertedProperties } : {}), - ...(required.length > 0 ? { required } : {}), - ...(convertedAdditionalProperties !== undefined - ? { additionalProperties: convertedAdditionalProperties } - : {}), - }; - } - - if ( - schema.type === "boolean" || - schema.type === "integer" || - schema.type === "number" || - schema.type === "string" - ) { - return { ...base, type: schema.type }; - } - - if (schema.type === "any") return base; - - return Object.keys(base).length > 0 ? base : {}; - }); - -const googleSchemaToJsonSchema = ( - rawSchema: unknown, -): Effect.Effect => - rawSchema === undefined - ? Effect.succeed({}) - : decodeDiscoverySchema(rawSchema).pipe(Effect.flatMap(discoverySchemaToJsonSchema)); - -const parameterToJsonSchema = ( - parameter: DiscoveryParameter, -): Effect.Effect => - parameter.repeated === true - ? discoverySchemaToJsonSchema( - toJsonSchemaSeed({ - type: parameter.type, - description: parameter.description, - properties: parameter.properties, - items: parameter.items, - additionalProperties: parameter.additionalProperties, - enum: parameter.enum, - format: parameter.format, - readOnly: parameter.readOnly, - default: parameter.default, - $ref: parameter.$ref, - required: [], - }), - ).pipe( - Effect.map((items) => ({ - type: "array", - items, - })), - ) - : discoverySchemaToJsonSchema( - toJsonSchemaSeed({ - type: parameter.type, - description: parameter.description, - properties: parameter.properties, - items: parameter.items, - additionalProperties: parameter.additionalProperties, - enum: parameter.enum, - format: parameter.format, - readOnly: parameter.readOnly, - default: parameter.default, - $ref: parameter.$ref, - required: [], - }), - ); - -const toToolPath = (service: string, methodId: string): string => { - const withoutPrefix = methodId.startsWith(`${service}.`) - ? methodId.slice(service.length + 1) - : methodId; - return withoutPrefix.trim(); -}; - -const toParameter = ( - name: string, - rawParameter: unknown, -): Effect.Effect => - Effect.gen(function* () { - const parameter = yield* decodeDiscoveryParameter(rawParameter); - if (parameter.location === undefined) return null; - - return GoogleDiscoveryParameter.make({ - name, - location: parameter.location, - required: parameter.required === true, - repeated: parameter.repeated === true, - description: parameter.description, - schema: Option.some(yield* parameterToJsonSchema(parameter)), - }); - }); - -const mergeParameters = (input: { - globalParameters: Readonly>; - method: DiscoveryMethod; -}): Effect.Effect => - Effect.gen(function* () { - const merged = new Map(); - - for (const [name, parameter] of Object.entries(input.globalParameters)) { - const converted = yield* toParameter(name, parameter); - if (converted) merged.set(name, converted); - } - - for (const [name, parameter] of Object.entries(input.method.parameters ?? {})) { - const converted = yield* toParameter(name, parameter); - if (converted) merged.set(name, converted); - } - - return [...merged.values()].sort((a, b) => a.name.localeCompare(b.name)); - }); - -const buildInputSchema = (input: { - parameters: readonly GoogleDiscoveryParameter[]; - requestRef: string | undefined; -}): unknown | undefined => { - const properties: Record = {}; - const required: string[] = []; - - for (const parameter of input.parameters) { - properties[parameter.name] = Option.getOrElse(parameter.schema, () => ({ - type: "string", - })); - if (parameter.required) required.push(parameter.name); - } - - if (input.requestRef) { - properties.body = { $ref: schemaRef(input.requestRef) }; - } - - if (Object.keys(properties).length === 0) return undefined; - - return { - type: "object", - properties, - ...(required.length > 0 ? { required } : {}), - additionalProperties: false, - }; -}; - -const extractScopes = (document: DiscoveryDocument): Record | undefined => { - const scopes = document.auth?.oauth2?.scopes ?? {}; - const normalized = Object.fromEntries( - Object.entries(scopes).map(([scope, value]) => [ - scope, - Option.getOrElse(value.description, () => ""), - ]), - ); - return Object.keys(normalized).length > 0 ? normalized : undefined; -}; - -const manifestMethodFromMethod = (input: { - service: string; - rawMethod: unknown; - globalParameters: Readonly>; -}): Effect.Effect => - Effect.gen(function* () { - const method = yield* decodeDiscoveryMethod(input.rawMethod); - const methodId = Option.getOrUndefined(method.id); - const path = Option.getOrUndefined(method.path); - if (!methodId || !path) return null; - if (!method.httpMethod) { - return yield* new GoogleDiscoveryParseError({ - message: `Google Discovery method '${methodId}' is missing httpMethod`, - }); - } - - const requestRef = method.request?.$ref; - const responseRef = method.response?.$ref; - const parameters = yield* mergeParameters({ - globalParameters: input.globalParameters, - method, - }); - - return GoogleDiscoveryManifestMethod.make({ - toolPath: toToolPath(input.service, methodId), - description: method.description, - binding: GoogleDiscoveryMethodBinding.make({ - method: method.httpMethod, - pathTemplate: path, - parameters, - hasBody: requestRef !== undefined, - }), - inputSchema: Option.fromNullishOr(buildInputSchema({ parameters, requestRef })), - outputSchema: Option.fromNullishOr( - responseRef ? { $ref: schemaRef(responseRef) } : undefined, - ), - scopes: method.scopes ?? [], - }); - }); - -const collectMethods = (input: { - service: string; - rawResource: unknown; - globalParameters: Readonly>; -}): Effect.Effect => - Effect.gen(function* () { - const resource = yield* decodeDiscoveryResource(input.rawResource); - const methods = yield* Effect.forEach(Object.values(resource.methods ?? {}), (rawMethod) => - manifestMethodFromMethod({ - service: input.service, - rawMethod, - globalParameters: input.globalParameters, - }), - ); - const nested = yield* Effect.forEach(Object.values(resource.resources ?? {}), (rawResource) => - collectMethods({ - ...input, - rawResource, - }), - ); - - return [...methods.flatMap((method) => (method ? [method] : [])), ...nested.flat()]; - }); - -export const extractGoogleDiscoveryManifest = Effect.fn("GoogleDiscovery.extractManifest")( - function* (discoveryDocument: string | JsonObject) { - const document = - typeof discoveryDocument === "string" - ? yield* decodeDiscoveryDocumentJson(discoveryDocument) - : yield* decodeDiscoveryDocument(discoveryDocument); - - const service = Option.getOrUndefined(document.name); - const version = Option.getOrUndefined(document.version); - const rootUrl = Option.getOrUndefined(document.rootUrl); - if (!service || !version || !rootUrl) { - return yield* new GoogleDiscoveryParseError({ - message: "Google Discovery document is missing one of: name, version, rootUrl", - }); - } - - const schemaDefinitions = Object.fromEntries( - yield* Effect.forEach(Object.entries(document.schemas ?? {}), ([name, rawSchema]) => - googleSchemaToJsonSchema(rawSchema).pipe(Effect.map((schema) => [name, schema] as const)), - ), - ); - - const topLevelMethods = yield* Effect.forEach( - Object.values(document.methods ?? {}), - (rawMethod) => - manifestMethodFromMethod({ - service, - rawMethod, - globalParameters: document.parameters ?? {}, - }), - ); - - const nestedMethods = yield* Effect.forEach( - Object.values(document.resources ?? {}), - (rawResource) => - collectMethods({ - service, - rawResource, - globalParameters: document.parameters ?? {}, - }), - ); - - return GoogleDiscoveryManifest.make({ - title: document.title, - service, - version, - rootUrl, - servicePath: document.servicePath ?? "", - oauthScopes: Option.fromNullishOr(extractScopes(document)), - schemaDefinitions, - methods: [ - ...topLevelMethods.flatMap((method) => (method ? [method] : [])), - ...nestedMethods.flat(), - ].sort((a, b) => a.toolPath.localeCompare(b.toolPath)), - }); - }, -); diff --git a/packages/plugins/google-discovery/src/sdk/errors.ts b/packages/plugins/google-discovery/src/sdk/errors.ts deleted file mode 100644 index 6c778a815..000000000 --- a/packages/plugins/google-discovery/src/sdk/errors.ts +++ /dev/null @@ -1,45 +0,0 @@ -// Google Discovery plugin tagged errors. The three errors that cross -// the HTTP edge carry an `HttpApiSchema` annotation so they can be -// `.addError(...)` directly on the API group — handlers return them -// and HttpApi encodes each as a 4xx response with a typed body, no -// per-handler sanitisation step. -// -// `GoogleDiscoveryInvocationError` stays a `Data.TaggedError` because -// it only surfaces through `invokeTool`, which runs under the core -// `tools.invoke` endpoint — not any endpoint on the Google Discovery -// group — so it doesn't need an HTTP annotation. - -import { Data, Schema } from "effect"; -import type { Option } from "effect"; - -export class GoogleDiscoveryParseError extends Schema.TaggedErrorClass()( - "GoogleDiscoveryParseError", - { - message: Schema.String, - }, - { httpApiStatus: 400 }, -) {} - -export class GoogleDiscoveryInvocationError extends Data.TaggedError( - "GoogleDiscoveryInvocationError", -)<{ - readonly message: string; - readonly statusCode: Option.Option; - readonly cause?: unknown; -}> {} - -export class GoogleDiscoveryOAuthError extends Schema.TaggedErrorClass()( - "GoogleDiscoveryOAuthError", - { - message: Schema.String, - }, - { httpApiStatus: 400 }, -) {} - -export class GoogleDiscoverySourceError extends Schema.TaggedErrorClass()( - "GoogleDiscoverySourceError", - { - message: Schema.String, - }, - { httpApiStatus: 400 }, -) {} diff --git a/packages/plugins/google-discovery/src/sdk/index.ts b/packages/plugins/google-discovery/src/sdk/index.ts deleted file mode 100644 index f8687441f..000000000 --- a/packages/plugins/google-discovery/src/sdk/index.ts +++ /dev/null @@ -1,36 +0,0 @@ -export { googleDiscoveryPlugin } from "./plugin"; -export type { - GoogleDiscoveryAddSourceInput, - GoogleDiscoveryPluginExtension, - GoogleDiscoveryProbeResult, - GoogleDiscoveryUpdateSourceInput, -} from "./plugin"; -export { extractGoogleDiscoveryManifest } from "./document"; -export { - googleDiscoverySchema, - makeGoogleDiscoveryStore, - GOOGLE_DISCOVERY_OAUTH_SESSION_TTL_MS, -} from "./binding-store"; -export type { - GoogleDiscoveryStore, - GoogleDiscoveryStoredSource, - GoogleDiscoverySchema, -} from "./binding-store"; -export { invokeGoogleDiscoveryTool, annotationsForOperation } from "./invoke"; -export { - GoogleDiscoveryAuth, - GoogleDiscoveryHttpMethod, - GoogleDiscoveryInvocationResult, - GoogleDiscoveryManifest, - GoogleDiscoveryManifestMethod, - GoogleDiscoveryMethodBinding, - GoogleDiscoveryParameter, - GoogleDiscoveryParameterLocation, - GoogleDiscoveryStoredSourceData, -} from "./types"; -export { - GoogleDiscoveryInvocationError, - GoogleDiscoveryOAuthError, - GoogleDiscoveryParseError, - GoogleDiscoverySourceError, -} from "./errors"; diff --git a/packages/plugins/google-discovery/src/sdk/invoke.ts b/packages/plugins/google-discovery/src/sdk/invoke.ts deleted file mode 100644 index fd52de59e..000000000 --- a/packages/plugins/google-discovery/src/sdk/invoke.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { Effect, Layer, Option, Schema } from "effect"; -import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; - -import type { PluginCtx, StorageFailure } from "@executor-js/sdk/core"; - -import { GoogleDiscoveryInvocationError, GoogleDiscoveryOAuthError } from "./errors"; -import type { GoogleDiscoveryStore } from "./binding-store"; -import { - GoogleDiscoveryInvocationResult, - type GoogleDiscoveryParameter, - type GoogleDiscoveryStoredSourceData, -} from "./types"; - -const SAFE_METHODS = new Set(["get", "head", "options"]); - -const UnknownErrorMessage = Schema.Struct({ message: Schema.String }); -const decodeUnknownErrorMessage = Schema.decodeUnknownOption(UnknownErrorMessage); - -const errorMessageFromUnknown = (cause: unknown): string => { - const decoded = decodeUnknownErrorMessage(cause); - if (Option.isSome(decoded)) return decoded.value.message; - // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: preserves existing fallback text for HTTP client errors - return String(cause); -}; - -export const annotationsForOperation = ( - method: string, - pathTemplate: string, -): { requiresApproval?: boolean; approvalDescription?: string } => { - if (SAFE_METHODS.has(method.toLowerCase())) return {}; - return { - requiresApproval: true, - approvalDescription: `${method.toUpperCase()} ${pathTemplate}`, - }; -}; - -// --------------------------------------------------------------------------- -// Path / query parameter helpers (unchanged from the old invoker) -// --------------------------------------------------------------------------- - -const stringValuesFromParameter = (value: unknown, repeated: boolean): string[] => { - if (value === undefined || value === null) return []; - if (Array.isArray(value)) { - const normalized = value.flatMap((entry) => - entry === undefined || entry === null ? [] : [String(entry)], - ); - return repeated ? normalized : [normalized.join(",")]; - } - if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { - return [String(value)]; - } - return [JSON.stringify(value)]; -}; - -const replacePathParameters = (input: { - pathTemplate: string; - args: Record; - parameters: readonly GoogleDiscoveryParameter[]; -}): Effect.Effect => - Effect.gen(function* () { - let failure: GoogleDiscoveryInvocationError | undefined; - const resolved = input.pathTemplate.replaceAll(/\{([^}]+)\}/g, (_, name: string) => { - const parameter = input.parameters.find( - (entry) => entry.location === "path" && entry.name === name, - ); - const values = stringValuesFromParameter(input.args[name], false); - if (values.length === 0) { - if (parameter?.required) { - failure = new GoogleDiscoveryInvocationError({ - message: `Missing required path parameter: ${name}`, - statusCode: Option.none(), - }); - } - return ""; - } - return encodeURIComponent(values[0]!); - }); - if (failure) return yield* failure; - return resolved; - }); - -const resolveBaseUrl = (source: GoogleDiscoveryStoredSourceData): string => - new URL(source.servicePath || "", source.rootUrl).toString(); - -const isJsonContentType = (contentType: string | null | undefined): boolean => { - if (!contentType) return false; - const normalized = contentType.split(";")[0]?.trim().toLowerCase() ?? ""; - return ( - normalized === "application/json" || normalized.includes("+json") || normalized.includes("json") - ); -}; - -// --------------------------------------------------------------------------- -// HTTP request builder / executor -// --------------------------------------------------------------------------- - -const performRequest = Effect.fn("GoogleDiscovery.invoke")(function* (input: { - method: string; - pathTemplate: string; - parameters: readonly GoogleDiscoveryParameter[]; - hasBody: boolean; - source: GoogleDiscoveryStoredSourceData; - args: Record; - authorizationHeader?: string; -}) { - const client = yield* HttpClient.HttpClient; - - const resolvedPath = yield* replacePathParameters({ - pathTemplate: input.pathTemplate, - args: input.args, - parameters: input.parameters, - }); - const requestUrl = new URL(resolvedPath.replace(/^\//, ""), resolveBaseUrl(input.source)); - - for (const parameter of input.parameters) { - if (parameter.location === "path") continue; - const values = stringValuesFromParameter(input.args[parameter.name], parameter.repeated); - if (values.length === 0) { - if (parameter.required) { - return yield* new GoogleDiscoveryInvocationError({ - message: `Missing required ${parameter.location} parameter: ${parameter.name}`, - statusCode: Option.none(), - }); - } - continue; - } - if (parameter.location === "query") { - for (const value of values) { - requestUrl.searchParams.append(parameter.name, value); - } - } - } - - let request = HttpClientRequest.make(input.method.toUpperCase() as "GET")(requestUrl.toString()); - - for (const parameter of input.parameters) { - if (parameter.location !== "header") continue; - const values = stringValuesFromParameter(input.args[parameter.name], parameter.repeated); - if (values.length === 0) continue; - request = HttpClientRequest.setHeader( - request, - parameter.name, - parameter.repeated ? values.join(",") : values[0]!, - ); - } - - if (input.authorizationHeader) { - request = HttpClientRequest.setHeader(request, "Authorization", input.authorizationHeader); - } - - if (input.hasBody && input.args.body !== undefined) { - request = HttpClientRequest.bodyJsonUnsafe(request, input.args.body); - } - - const response = yield* client.execute(request).pipe( - Effect.mapError( - (err) => - new GoogleDiscoveryInvocationError({ - message: `HTTP request failed: ${errorMessageFromUnknown(err)}`, - statusCode: Option.none(), - cause: err, - }), - ), - ); - - const contentType = response.headers["content-type"] ?? null; - const mapBodyError = Effect.mapError( - (err: unknown) => - new GoogleDiscoveryInvocationError({ - message: `Failed to read response body: ${errorMessageFromUnknown(err)}`, - statusCode: Option.some(response.status), - cause: err, - }), - ); - const body = - response.status === 204 - ? null - : isJsonContentType(contentType) - ? yield* response.json.pipe( - Effect.catch(() => response.text), - mapBodyError, - ) - : yield* response.text.pipe(mapBodyError); - - const ok = response.status >= 200 && response.status < 300; - return GoogleDiscoveryInvocationResult.make({ - status: response.status, - headers: { ...response.headers }, - data: ok ? body : null, - error: ok ? null : body, - }); -}); - -// --------------------------------------------------------------------------- -// Entry point — called from plugin.invokeTool. -// --------------------------------------------------------------------------- - -export const invokeGoogleDiscoveryTool = (input: { - ctx: PluginCtx; - toolId: string; - /** Resolved owning scope of the tool row. */ - toolScope: string; - args: unknown; - httpClientLayer?: Layer.Layer; -}): Effect.Effect< - GoogleDiscoveryInvocationResult, - GoogleDiscoveryInvocationError | GoogleDiscoveryOAuthError | StorageFailure -> => - Effect.gen(function* () { - const entry = yield* input.ctx.storage.getBinding(input.toolId, input.toolScope); - if (!entry) { - return yield* new GoogleDiscoveryInvocationError({ - message: `No Google Discovery operation found for tool "${input.toolId}"`, - statusCode: Option.none(), - }); - } - const stored = yield* input.ctx.storage.getSource(entry.namespace, input.toolScope); - if (!stored) { - return yield* new GoogleDiscoveryInvocationError({ - message: `No Google Discovery source found for "${entry.namespace}"`, - statusCode: Option.none(), - }); - } - const source = stored.config; - - const authHeader = - source.auth.kind === "oauth2" - ? `Bearer ${yield* input.ctx.connections.accessToken(source.auth.connectionId).pipe( - Effect.mapError( - (err) => - new GoogleDiscoveryOAuthError({ - message: errorMessageFromUnknown(err), - }), - ), - )}` - : undefined; - - const layer = input.httpClientLayer ?? FetchHttpClient.layer; - - return yield* performRequest({ - method: entry.binding.method, - pathTemplate: entry.binding.pathTemplate, - parameters: entry.binding.parameters, - hasBody: entry.binding.hasBody, - source, - args: (input.args ?? {}) as Record, - authorizationHeader: authHeader, - }).pipe(Effect.provide(layer)) as Effect.Effect< - GoogleDiscoveryInvocationResult, - GoogleDiscoveryInvocationError, - never - >; - }); diff --git a/packages/plugins/google-discovery/src/sdk/option-json.test.ts b/packages/plugins/google-discovery/src/sdk/option-json.test.ts deleted file mode 100644 index 4a87e10df..000000000 --- a/packages/plugins/google-discovery/src/sdk/option-json.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -// Covers the Option JSON boundary using Effect-native primitives only: -// no JSON.parse, no JSON.stringify, no node:fs on our side. We split the -// boundary into two Effect schema steps: -// -// 1. Schema.encodeEffect(Inner)(value) → encoded JS shape -// 2. Schema.encodeEffect(UnknownFromJsonString) → JSON string -// 3. fs.writeFileString → fs.readFileString -// 4. Schema.decodeUnknownEffect(UnknownFromJsonString) → unknown -// 5. Schema.decodeUnknownEffect(Inner) → final value -// -// Even though every step runs through Effect, Schema.Option(X) still -// breaks because its Encoded type IS Option — not a JSON value. So -// step 2's JSON-stringify (driven by Effect, not us) flattens the Option -// to {_id,_tag,value}, and step 5 rejects the shape. -// -// Run: vitest run packages/plugins/google-discovery/src/sdk/option-json.test.ts - -import { describe, expect, it } from "@effect/vitest"; -import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"; -import { Effect, Exit, FileSystem, Option, Schema } from "effect"; - -const Broken = Schema.Struct({ description: Schema.Option(Schema.String) }); -const Fixed = Schema.Struct({ description: Schema.OptionFromOptional(Schema.String) }); - -const broken = { description: Option.some("hello") }; -const fixed = { description: Option.some("hello") }; - -const withTmpFile = (fn: (path: string) => Effect.Effect) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "option-json-" }); - return yield* fn(`${dir}/binding.json`); - }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)); - -describe("Schema.Option round-trips through Effect-native JSON I/O", () => { - it.effect("BREAKS: every step driven by Effect, still loses the Option shape", () => - withTmpFile((path) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - - // Step 1: schema encode → encoded JS shape. For Schema.Option, - // the encoded `description` is still an Option instance. - const encodedShape = yield* Schema.encodeEffect(Broken)(broken); - expect(Option.isOption(encodedShape.description)).toBe(true); - - // Step 2: turn the encoded shape into a JSON string via Effect. - const jsonString = yield* Schema.encodeEffect(Schema.UnknownFromJsonString)(encodedShape); - // This is what Effect produced — Option's toJSON shape: - expect(jsonString).toContain('"_id":"Option"'); - expect(jsonString).toContain('"_tag":"Some"'); - - // Step 3 + 4: round-trip via the platform FileSystem. - yield* fs.writeFileString(path, jsonString); - const onDisk = yield* fs.readFileString(path); - - // Step 5: parse string → unknown via Effect. - const parsed = yield* Schema.decodeUnknownEffect(Schema.UnknownFromJsonString)(onDisk); - - // Step 6: decode the unknown back through the schema → fails, - // because the wire shape isn't an Option instance. - const result = yield* Effect.exit(Schema.decodeUnknownEffect(Broken)(parsed)); - expect(Exit.isFailure(result)).toBe(true); - }), - ), - ); - - it.effect("WORKS: same pipeline with Schema.OptionFromOptional", () => - withTmpFile((path) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - - const encodedShape = yield* Schema.encodeEffect(Fixed)(fixed); - // Encoded form is JSON-safe: { description: "hello" } - expect(encodedShape.description).toBe("hello"); - - const jsonString = yield* Schema.encodeEffect(Schema.UnknownFromJsonString)(encodedShape); - expect(jsonString).toBe('{"description":"hello"}'); - - yield* fs.writeFileString(path, jsonString); - const onDisk = yield* fs.readFileString(path); - - const parsed = yield* Schema.decodeUnknownEffect(Schema.UnknownFromJsonString)(onDisk); - const decoded = yield* Schema.decodeUnknownEffect(Fixed)(parsed); - - expect(Option.getOrNull(decoded.description)).toBe("hello"); - }), - ), - ); - - it.effect("WORKS: None round-trips as a missing key with OptionFromOptional", () => - withTmpFile((path) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const noneVal = { description: Option.none() }; - - const encodedShape = yield* Schema.encodeEffect(Fixed)(noneVal); - const jsonString = yield* Schema.encodeEffect(Schema.UnknownFromJsonString)(encodedShape); - expect(jsonString).toBe("{}"); - - yield* fs.writeFileString(path, jsonString); - const onDisk = yield* fs.readFileString(path); - const parsed = yield* Schema.decodeUnknownEffect(Schema.UnknownFromJsonString)(onDisk); - const decoded = yield* Schema.decodeUnknownEffect(Fixed)(parsed); - - expect(Option.isNone(decoded.description)).toBe(true); - }), - ), - ); -}); diff --git a/packages/plugins/google-discovery/src/sdk/plugin.test.ts b/packages/plugins/google-discovery/src/sdk/plugin.test.ts deleted file mode 100644 index 87a377d9c..000000000 --- a/packages/plugins/google-discovery/src/sdk/plugin.test.ts +++ /dev/null @@ -1,676 +0,0 @@ -import { createServer, type Server } from "node:http"; -import { readFileSync } from "node:fs"; -import { resolve } from "node:path"; - -import { describe, expect, it, vi } from "@effect/vitest"; -import { Effect, Schema } from "effect"; - -import { - ConnectionId, - CreateConnectionInput, - createExecutor, - Scope, - ScopeId, - SecretId, - SetSecretInput, - TokenMaterial, - type InvokeOptions, -} from "@executor-js/sdk"; -import { makeTestConfig } from "@executor-js/sdk/testing"; - -import { googleDiscoveryPlugin } from "./plugin"; - -const autoApprove: InvokeOptions = { onElicitation: "accept-all" }; - -const fixturePath = resolve(__dirname, "../../fixtures/drive.json"); -const fixtureText = readFileSync(fixturePath, "utf8"); -const DiscoveryFixtureJson = Schema.Record(Schema.String, Schema.Unknown); -const fixtureJson = Schema.decodeUnknownSync(Schema.fromJsonString(DiscoveryFixtureJson))( - fixtureText, -); - -// --------------------------------------------------------------------------- -// Test HTTP server — serves the discovery document and echoes API calls. -// --------------------------------------------------------------------------- - -interface ServerHandle { - readonly baseUrl: string; - readonly discoveryUrl: string; - readonly requests: Array<{ - method: string; - url: string; - headers: Record; - body: string; - }>; - readonly close: () => Promise; -} - -const startServer = (): Promise => - new Promise((resolvePromise, rejectPromise) => { - const requests: ServerHandle["requests"] = []; - - const server: Server = createServer(async (request, response) => { - const chunks: Buffer[] = []; - for await (const chunk of request) { - chunks.push(Buffer.from(chunk)); - } - const body = Buffer.concat(chunks).toString("utf8"); - const url = request.url ?? "/"; - - requests.push({ - method: request.method ?? "GET", - url, - headers: request.headers, - body, - }); - - if (url === "/$discovery/rest?version=v3") { - const address = server.address(); - if (!address || typeof address === "string") { - response.statusCode = 500; - response.end(); - return; - } - const dynamicFixture = JSON.stringify({ - ...fixtureJson, - rootUrl: `http://127.0.0.1:${address.port}/`, - }); - response.statusCode = 200; - response.setHeader("content-type", "application/json"); - response.end(dynamicFixture); - return; - } - - response.statusCode = 200; - response.setHeader("content-type", "application/json"); - response.end(JSON.stringify({ id: "123", name: "Quarterly Plan" })); - }); - - server.listen(0, "127.0.0.1", (error?: Error) => { - if (error) { - // oxlint-disable-next-line executor/no-promise-reject -- boundary: node listen callback reports startup failure through Promise adapter - rejectPromise(error); - return; - } - const address = server.address(); - if (!address || typeof address === "string") { - // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: node listen callback reports startup failure through Promise adapter - rejectPromise(new Error("Failed to resolve test server address")); - return; - } - const baseUrl = `http://127.0.0.1:${address.port}`; - resolvePromise({ - baseUrl, - discoveryUrl: `${baseUrl}/$discovery/rest?version=v3`, - requests, - close: () => - new Promise((resolveClose, rejectClose) => { - // oxlint-disable-next-line executor/no-promise-reject -- boundary: node close callback reports shutdown failure through Promise adapter - server.close((err) => (err ? rejectClose(err) : resolveClose())); - }), - }); - }); - }); - -const TestServer = Effect.acquireRelease( - Effect.promise(() => startServer()), - (handle) => Effect.promise(() => handle.close()).pipe(Effect.ignore), -); - -// --------------------------------------------------------------------------- -// Memory secret provider plugin — lets the test store secrets with -// `executor.secrets.set` / `ctx.secrets.set`. Without this there's no -// writable provider registered against the test executor. -// --------------------------------------------------------------------------- - -import { definePlugin, type SecretProvider } from "@executor-js/sdk"; - -const makeMemorySecretsPlugin = () => { - const store = new Map(); - const provider: SecretProvider = { - key: "memory", - writable: true, - get: (id, scope) => Effect.sync(() => store.get(`${scope}\u0000${id}`) ?? null), - set: (id, value, scope) => - Effect.sync(() => { - store.set(`${scope}\u0000${id}`, value); - }), - delete: (id, scope) => Effect.sync(() => store.delete(`${scope}\u0000${id}`)), - list: () => - Effect.sync(() => - Array.from(store.keys()).map((k) => { - const name = k.split("\u0000", 2)[1] ?? k; - return { id: name, name }; - }), - ), - }; - return definePlugin(() => ({ - id: "memory-secrets" as const, - storage: () => ({}), - secretProviders: [provider], - })); -}; - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe("Google Discovery plugin", () => { - it.effect("normalizes legacy googleapis discovery urls", () => - Effect.gen(function* () { - const executor = yield* Effect.acquireRelease( - createExecutor( - makeTestConfig({ - plugins: [makeMemorySecretsPlugin()(), googleDiscoveryPlugin()] as const, - }), - ), - (executor) => executor.close().pipe(Effect.ignore), - ); - - const originalFetch = globalThis.fetch; - const fetchMock = yield* Effect.acquireRelease( - Effect.sync(() => - vi.spyOn(globalThis, "fetch").mockImplementation((( - input: RequestInfo | URL, - init?: RequestInit, - ) => { - const url = - typeof input === "string" - ? input - : input instanceof URL - ? input.toString() - : input.url; - if (url === "https://www.googleapis.com/discovery/v1/apis/drive/v3/rest") { - return Promise.resolve( - new Response(fixtureText, { - status: 200, - headers: { "content-type": "application/json" }, - }), - ); - } - return originalFetch(input, init); - }) as typeof fetch), - ), - (mock) => Effect.sync(() => mock.mockRestore()), - ); - - const result = yield* executor.googleDiscovery.probeDiscovery( - "https://drive.googleapis.com/$discovery/rest?version=v3", - ); - expect(result.service).toBe("drive"); - expect(fetchMock).toHaveBeenCalledWith( - "https://www.googleapis.com/discovery/v1/apis/drive/v3/rest", - expect.objectContaining({ signal: expect.any(AbortSignal) }), - ); - }), - ); - - // OAuth start/complete are driven via ctx.oauth now — the UI stitches - // the strategy config (Google endpoints + extras) and calls the shared - // /scopes/:scopeId/oauth/{start,complete} surface. The connection id - // is chosen client-side and stamped onto the source's auth config. - - it.effect("starts oauth using caller-supplied discovery scopes", () => - Effect.gen(function* () { - const handle = yield* TestServer; - const executor = yield* Effect.acquireRelease( - createExecutor( - makeTestConfig({ - plugins: [makeMemorySecretsPlugin()(), googleDiscoveryPlugin()] as const, - }), - ), - (executor) => executor.close().pipe(Effect.ignore), - ); - - yield* executor.secrets.set( - SetSecretInput.make({ - id: SecretId.make("google-client-id"), - scope: "test-scope" as SetSecretInput["scope"], - name: "Google Client ID", - value: "client-123", - }), - ); - - const connectionId = "google-discovery-oauth2-test-start"; - const result = yield* executor.oauth.start({ - endpoint: handle.discoveryUrl, - redirectUrl: "http://localhost/callback", - connectionId, - tokenScope: "test-scope", - strategy: { - kind: "authorization-code", - authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth", - tokenEndpoint: "https://oauth2.googleapis.com/token", - clientIdSecretId: "google-client-id", - clientSecretSecretId: null, - scopes: ["https://www.googleapis.com/auth/drive"], - extraAuthorizationParams: { - access_type: "offline", - include_granted_scopes: "true", - prompt: "consent", - }, - }, - pluginId: "google-discovery", - }); - - expect(result.authorizationUrl).not.toBeNull(); - const authorizationUrl = new URL(result.authorizationUrl ?? "about:blank"); - expect(authorizationUrl.searchParams.get("client_id")).toBe("client-123"); - expect(authorizationUrl.searchParams.get("access_type")).toBe("offline"); - expect(authorizationUrl.searchParams.get("prompt")).toBe("consent"); - expect(authorizationUrl.searchParams.get("scope")).toBe( - "https://www.googleapis.com/auth/drive", - ); - }), - ); - - it.effect("completes oauth and stores token secrets on a connection", () => - Effect.gen(function* () { - const handle = yield* TestServer; - const executor = yield* Effect.acquireRelease( - createExecutor( - makeTestConfig({ - plugins: [makeMemorySecretsPlugin()(), googleDiscoveryPlugin()] as const, - }), - ), - (executor) => executor.close().pipe(Effect.ignore), - ); - - yield* executor.secrets.set( - SetSecretInput.make({ - id: SecretId.make("google-client-id"), - scope: "test-scope" as SetSecretInput["scope"], - name: "Google Client ID", - value: "client-123", - }), - ); - yield* executor.secrets.set( - SetSecretInput.make({ - id: SecretId.make("google-client-secret"), - scope: "test-scope" as SetSecretInput["scope"], - name: "Google Client Secret", - value: "client-secret-value", - }), - ); - - const originalFetch = globalThis.fetch; - let tokenRequestInit: RequestInit | undefined; - yield* Effect.acquireRelease( - Effect.sync(() => - vi.spyOn(globalThis, "fetch").mockImplementation((( - input: RequestInfo | URL, - init?: RequestInit, - ) => { - const url = - typeof input === "string" - ? input - : input instanceof URL - ? input.toString() - : input.url; - if (url === "https://oauth2.googleapis.com/token") { - tokenRequestInit = init; - return Promise.resolve( - new Response( - JSON.stringify({ - access_token: "access-token-value", - refresh_token: "refresh-token-value", - token_type: "Bearer", - expires_in: 3600, - scope: "https://www.googleapis.com/auth/drive", - }), - { status: 200, headers: { "content-type": "application/json" } }, - ), - ); - } - return originalFetch(input, init); - }) as typeof fetch), - ), - (mock) => Effect.sync(() => mock.mockRestore()), - ); - - const connectionId = "google-discovery-oauth2-test-complete"; - const started = yield* executor.oauth.start({ - endpoint: handle.discoveryUrl, - redirectUrl: "http://localhost/callback", - connectionId, - tokenScope: "test-scope", - strategy: { - kind: "authorization-code", - authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth", - tokenEndpoint: "https://oauth2.googleapis.com/token", - clientIdSecretId: "google-client-id", - clientSecretSecretId: "google-client-secret", - scopes: ["https://www.googleapis.com/auth/drive"], - extraAuthorizationParams: { - access_type: "offline", - include_granted_scopes: "true", - prompt: "consent", - }, - }, - pluginId: "google-discovery", - }); - - const completed = yield* executor.oauth.complete({ - state: started.sessionId, - code: "code-123", - }); - - expect(completed.connectionId).toBe(connectionId); - expect(tokenRequestInit?.method).toBe("POST"); - - // Tokens live on the SDK connection — resolving via - // ctx.connections.accessToken returns the minted value. - const accessToken = yield* executor.connections.accessToken( - completed.connectionId as Parameters[0], - ); - expect(accessToken).toBe("access-token-value"); - - // Backing access-token secret is owned by the connection, so - // it's filtered out of the user-facing secret list. - const secretIds = new Set((yield* executor.secrets.list()).map((s) => String(s.id))); - expect(secretIds).not.toContain(`${completed.connectionId}.access_token`); - expect(secretIds).not.toContain(`${completed.connectionId}.refresh_token`); - }), - ); - - it.effect("registers and invokes google discovery tools with oauth headers", () => - Effect.gen(function* () { - const handle = yield* TestServer; - const executor = yield* Effect.acquireRelease( - createExecutor( - makeTestConfig({ - plugins: [makeMemorySecretsPlugin()(), googleDiscoveryPlugin()] as const, - }), - ), - (executor) => executor.close().pipe(Effect.ignore), - ); - - // A connection wraps the access token (+ optional refresh) and - // the invoke path resolves via ctx.connections.accessToken. - const connectionId = ConnectionId.make("google-discovery-oauth2-test"); - yield* executor.connections.create( - CreateConnectionInput.make({ - id: connectionId, - scope: ScopeId.make("test-scope"), - provider: "oauth2", - identityLabel: "Drive Test", - accessToken: TokenMaterial.make({ - secretId: SecretId.make(`${connectionId}.access_token`), - name: "Drive Access Token", - value: "secret-token", - }), - refreshToken: null, - expiresAt: null, - oauthScope: null, - providerState: { - clientIdSecretId: "drive-client-id", - clientSecretSecretId: null, - scopes: ["https://www.googleapis.com/auth/drive.readonly"], - }, - }), - ); - - const result = yield* executor.googleDiscovery.addSource({ - name: "Google Drive", - scope: "test-scope", - discoveryUrl: handle.discoveryUrl, - namespace: "drive", - auth: { - kind: "oauth2", - connectionId, - clientIdSecretId: "drive-client-id", - clientSecretSecretId: null, - scopes: ["https://www.googleapis.com/auth/drive.readonly"], - }, - }); - - expect(result.toolCount).toBe(2); - expect((yield* executor.tools.list()).map((tool) => tool.id)).toEqual( - expect.arrayContaining([ - "executor.googleDiscovery.probeDiscovery", - "executor.googleDiscovery.addSource", - "executor.googleDiscovery.getSource", - "executor.googleDiscovery.configureSource", - ]), - ); - - const inspected = yield* executor.tools.invoke( - "executor.googleDiscovery.getSource", - { namespace: "drive", scope: "test-scope" }, - autoApprove, - ); - expect(inspected).toMatchObject({ - ok: true, - data: { source: { namespace: "drive", scope: "test-scope" } }, - }); - - const invocation = (yield* executor.tools.invoke( - "drive.files.get", - { fileId: "123", fields: "id,name", prettyPrint: true }, - autoApprove, - )) as { readonly ok: true; readonly data: { status: number; data: unknown } }; - - expect(invocation.ok).toBe(true); - expect(invocation.data.data).toEqual({ id: "123", name: "Quarterly Plan" }); - - const apiRequest = handle.requests.find((request) => - request.url.startsWith("/drive/v3/files/123"), - ); - expect(apiRequest).toBeDefined(); - expect(apiRequest!.headers.authorization).toBe("Bearer secret-token"); - expect(apiRequest!.url).toContain("fields=id%2Cname"); - expect(apiRequest!.url).toContain("prettyPrint=true"); - }), - ); - - // ------------------------------------------------------------------------- - // Multi-scope shadowing — regression suite covering the bug class where - // store reads/writes that don't pin scope_id collapse onto whichever visible - // row wins first. Each - // scenario is reproducible against the pre-fix store. - // ------------------------------------------------------------------------- - - const ORG_SCOPE = ScopeId.make("org-scope"); - const USER_SCOPE = ScopeId.make("user-scope"); - const ORG_SCOPE_STRING = String(ORG_SCOPE); - const USER_SCOPE_STRING = String(USER_SCOPE); - - const stackedScopes = [ - Scope.make({ id: USER_SCOPE, name: "user", createdAt: new Date() }), - Scope.make({ id: ORG_SCOPE, name: "org", createdAt: new Date() }), - ] as const; - - it.effect("shadowed addSource does not wipe the outer-scope source", () => - Effect.gen(function* () { - const handle = yield* TestServer; - const executor = yield* Effect.acquireRelease( - createExecutor( - makeTestConfig({ - scopes: stackedScopes, - plugins: [makeMemorySecretsPlugin()(), googleDiscoveryPlugin()] as const, - }), - ), - (executor) => executor.close().pipe(Effect.ignore), - ); - // Org-level base source - yield* executor.googleDiscovery.addSource({ - name: "Org Drive", - scope: ORG_SCOPE_STRING, - discoveryUrl: handle.discoveryUrl, - namespace: "shared", - auth: { kind: "none" }, - }); - - // Per-user shadow with the same namespace - yield* executor.googleDiscovery.addSource({ - name: "User Drive", - scope: USER_SCOPE_STRING, - discoveryUrl: handle.discoveryUrl, - namespace: "shared", - auth: { kind: "none" }, - }); - - const userView = yield* executor.googleDiscovery.getSource("shared", USER_SCOPE_STRING); - const orgView = yield* executor.googleDiscovery.getSource("shared", ORG_SCOPE_STRING); - - // Both rows must coexist — innermost-wins reads come from the - // executor; the store's scope-pinned getters return the exact row. - expect(userView?.name).toBe("User Drive"); - expect(userView?.scope).toBe(USER_SCOPE_STRING); - expect(orgView?.name).toBe("Org Drive"); - expect(orgView?.scope).toBe(ORG_SCOPE_STRING); - }), - ); - - it.effect("removeSource on user shadow leaves the org row intact", () => - Effect.gen(function* () { - const handle = yield* TestServer; - const executor = yield* Effect.acquireRelease( - createExecutor( - makeTestConfig({ - scopes: stackedScopes, - plugins: [makeMemorySecretsPlugin()(), googleDiscoveryPlugin()] as const, - }), - ), - (executor) => executor.close().pipe(Effect.ignore), - ); - yield* executor.googleDiscovery.addSource({ - name: "Org Drive", - scope: ORG_SCOPE_STRING, - discoveryUrl: handle.discoveryUrl, - namespace: "shared", - auth: { kind: "none" }, - }); - yield* executor.googleDiscovery.addSource({ - name: "User Drive", - scope: USER_SCOPE_STRING, - discoveryUrl: handle.discoveryUrl, - namespace: "shared", - auth: { kind: "none" }, - }); - - yield* executor.googleDiscovery.removeSource("shared", USER_SCOPE_STRING); - - const userView = yield* executor.googleDiscovery.getSource("shared", USER_SCOPE_STRING); - const orgView = yield* executor.googleDiscovery.getSource("shared", ORG_SCOPE_STRING); - - expect(userView).toBeNull(); - expect(orgView?.name).toBe("Org Drive"); - }), - ); - - it.effect("re-adding a user shadow does not wipe the org row's bindings", () => - Effect.gen(function* () { - const handle = yield* TestServer; - const executor = yield* Effect.acquireRelease( - createExecutor( - makeTestConfig({ - scopes: stackedScopes, - plugins: [makeMemorySecretsPlugin()(), googleDiscoveryPlugin()] as const, - }), - ), - (executor) => executor.close().pipe(Effect.ignore), - ); - yield* executor.googleDiscovery.addSource({ - name: "Org Drive", - scope: ORG_SCOPE_STRING, - discoveryUrl: handle.discoveryUrl, - namespace: "shared", - auth: { kind: "none" }, - }); - // Add user shadow, then add it again — the internal - // registerManifest sequence does a scope-pinned - // removeBindingsBySource before re-upserting. Without pinning - // scope, the inner re-add would wipe the org-level bindings - // via fall-through. - yield* executor.googleDiscovery.addSource({ - name: "User Drive v1", - scope: USER_SCOPE_STRING, - discoveryUrl: handle.discoveryUrl, - namespace: "shared", - auth: { kind: "none" }, - }); - yield* executor.googleDiscovery.addSource({ - name: "User Drive v2", - scope: USER_SCOPE_STRING, - discoveryUrl: handle.discoveryUrl, - namespace: "shared", - auth: { kind: "none" }, - }); - - const userView = yield* executor.googleDiscovery.getSource("shared", USER_SCOPE_STRING); - const orgView = yield* executor.googleDiscovery.getSource("shared", ORG_SCOPE_STRING); - - expect(userView?.name).toBe("User Drive v2"); - expect(userView?.scope).toBe(USER_SCOPE_STRING); - expect(orgView?.name).toBe("Org Drive"); - expect(orgView?.scope).toBe(ORG_SCOPE_STRING); - }), - ); - - // ------------------------------------------------------------------------- - // Usage tracking — refs land on auth_* columns and the credential - // child tables. `usagesForSecret` / `usagesForConnection` should - // surface them all. - // ------------------------------------------------------------------------- - - it.effect("usagesForSecret returns refs across auth + credential rows", () => - Effect.gen(function* () { - const handle = yield* TestServer; - const executor = yield* Effect.acquireRelease( - createExecutor( - makeTestConfig({ - plugins: [makeMemorySecretsPlugin()(), googleDiscoveryPlugin()] as const, - }), - ), - (executor) => executor.close().pipe(Effect.ignore), - ); - const connectionId = ConnectionId.make("google-discovery-oauth2-usages"); - yield* executor.connections.create( - CreateConnectionInput.make({ - id: connectionId, - scope: ScopeId.make("test-scope"), - provider: "oauth2", - identityLabel: "Drive Usages", - accessToken: TokenMaterial.make({ - secretId: SecretId.make(`${connectionId}.access_token`), - name: "Drive Access Token", - value: "secret-token", - }), - refreshToken: null, - expiresAt: null, - oauthScope: null, - providerState: null, - }), - ); - - yield* executor.googleDiscovery.addSource({ - name: "Drive (Usages)", - scope: "test-scope", - discoveryUrl: handle.discoveryUrl, - namespace: "drive_u", - auth: { - kind: "oauth2", - connectionId, - clientIdSecretId: "shared-secret", - clientSecretSecretId: null, - scopes: [], - }, - }); - - // The auth.client_id_secret_id alone holds `shared-secret`. - const usages = yield* executor.secrets.usages(SecretId.make("shared-secret")); - expect(usages.length).toBe(1); - expect(usages[0]).toMatchObject({ - pluginId: "google-discovery", - ownerKind: "google-discovery-source", - ownerId: "drive_u", - slot: "auth.oauth2.client_id", - }); - - const connUsages = yield* executor.connections.usages(connectionId); - expect(connUsages.length).toBe(1); - expect(connUsages[0].slot).toBe("auth.oauth2.connection"); - }), - ); -}); diff --git a/packages/plugins/google-discovery/src/sdk/plugin.ts b/packages/plugins/google-discovery/src/sdk/plugin.ts deleted file mode 100644 index bc8bf119a..000000000 --- a/packages/plugins/google-discovery/src/sdk/plugin.ts +++ /dev/null @@ -1,825 +0,0 @@ -import { Effect, Option, Predicate, Schema } from "effect"; - -import { - ScopeId, - SourceDetectionResult, - ToolResult, - Usage, - defaultSourceInstallScopeId, - definePlugin, - tool, - resolveSecretBackedMap, - type PluginCtx, - type StaticToolSchema, - type StorageFailure, - type ToolAnnotations, -} from "@executor-js/sdk/core"; - -import { - googleDiscoverySchema, - makeGoogleDiscoveryStore, - type GoogleDiscoveryStore, -} from "./binding-store"; -import { googleDiscoveryPresets } from "./presets"; -import { extractGoogleDiscoveryManifest } from "./document"; -import { annotationsForOperation, invokeGoogleDiscoveryTool } from "./invoke"; -import { GoogleDiscoveryParseError, GoogleDiscoverySourceError } from "./errors"; -import { - GoogleDiscoveryAuth, - GoogleDiscoveryFetchCredentials, - GoogleDiscoveryStoredSourceData as GoogleDiscoveryStoredSourceDataSchema, - type GoogleDiscoveryManifest, - type GoogleDiscoveryManifestMethod, - type GoogleDiscoveryMethodBinding, -} from "./types"; -import type { GoogleDiscoveryStoredSourceData } from "./types"; - -// --------------------------------------------------------------------------- -// Upstream-error message extraction -// --------------------------------------------------------------------------- - -const GOOGLE_BODY_CAP = 1024; -const UpstreamMessageBody = Schema.Struct({ message: Schema.String }); -const UpstreamErrorMessageBody = Schema.Struct({ errorMessage: Schema.String }); -const UpstreamNestedErrorBody = Schema.Struct({ error: UpstreamMessageBody }); -const UpstreamErrorsArrayBody = Schema.Struct({ - errors: Schema.Array( - Schema.Struct({ - detail: Schema.optional(Schema.String), - message: Schema.optional(Schema.String), - title: Schema.optional(Schema.String), - }), - ), -}); - -const decodeUpstreamMessageBody = Schema.decodeUnknownOption(UpstreamMessageBody); -const decodeUpstreamErrorMessageBody = Schema.decodeUnknownOption(UpstreamErrorMessageBody); -const decodeUpstreamNestedErrorBody = Schema.decodeUnknownOption(UpstreamNestedErrorBody); -const decodeUpstreamErrorsArrayBody = Schema.decodeUnknownOption(UpstreamErrorsArrayBody); - -const googleClampedStringify = (value: unknown): string => { - let s: string; - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: JSON.stringify may throw on cycles; fall back to String() so the upstream body can still be surfaced as ToolError.details fallback text - try { - s = JSON.stringify(value); - } catch { - s = String(value); - } - return s.length > GOOGLE_BODY_CAP ? `${s.slice(0, GOOGLE_BODY_CAP)}…` : s; -}; - -const firstNonEmpty = (...values: readonly (string | undefined)[]): string | undefined => - values.find((value) => value !== undefined && value.length > 0); - -const googleExtractUpstreamMessage = (body: unknown, status: number): string => { - if (typeof body === "string") { - return body.length > 0 ? body : `Upstream returned HTTP ${status}`; - } - const nested = Option.getOrUndefined(decodeUpstreamNestedErrorBody(body)); - const messageBody = Option.getOrUndefined(decodeUpstreamMessageBody(body)); - const errorMessageBody = Option.getOrUndefined(decodeUpstreamErrorMessageBody(body)); - const errorsBody = Option.getOrUndefined(decodeUpstreamErrorsArrayBody(body)); - const arrayMessage = errorsBody?.errors - .map(({ detail, message: upstreamMessage, title }) => - firstNonEmpty(detail, upstreamMessage, title), - ) - .find((message) => message !== undefined); - const message = firstNonEmpty( - nested?.error.message, - messageBody?.message, - errorMessageBody?.errorMessage, - arrayMessage, - ); - if (message !== undefined) return message; - if (body !== null && typeof body === "object") { - return googleClampedStringify(body); - } - return `Upstream returned HTTP ${status}`; -}; - -// --------------------------------------------------------------------------- -// Public input / output shapes -// --------------------------------------------------------------------------- - -export interface GoogleDiscoveryProbeOperation { - readonly toolPath: string; - readonly method: string; - readonly pathTemplate: string; - readonly description: string | null; -} - -export interface GoogleDiscoveryProbeResult { - readonly name: string; - readonly title: string | null; - readonly service: string; - readonly version: string; - readonly toolCount: number; - readonly scopes: readonly string[]; - readonly operations: readonly GoogleDiscoveryProbeOperation[]; -} - -export interface GoogleDiscoveryUpdateSourceInput { - readonly name?: string; - /** Rewrite the source's auth — typically after a successful - * re-authenticate, to point at a freshly minted Connection. */ - readonly auth?: GoogleDiscoveryAuth; -} - -const GoogleDiscoveryProbeInputSchema = Schema.Struct({ - discoveryUrl: Schema.String, - credentials: Schema.optional(GoogleDiscoveryFetchCredentials), -}); - -const GoogleDiscoveryProbeOutputSchema = Schema.Struct({ - name: Schema.String, - title: Schema.NullOr(Schema.String), - service: Schema.String, - version: Schema.String, - toolCount: Schema.Number, - scopes: Schema.Array(Schema.String), - operations: Schema.Array( - Schema.Struct({ - toolPath: Schema.String, - method: Schema.String, - pathTemplate: Schema.String, - description: Schema.NullOr(Schema.String), - }), - ), -}); - -const GoogleDiscoveryAddSourceInputSchema = Schema.Struct({ - name: Schema.String, - scope: Schema.String, - discoveryUrl: Schema.String, - credentials: Schema.optional(GoogleDiscoveryFetchCredentials), - namespace: Schema.optional(Schema.String), - auth: GoogleDiscoveryAuth, -}); -const GoogleDiscoveryStaticAddSourceInputSchema = Schema.Struct({ - name: Schema.String, - discoveryUrl: Schema.String, - credentials: Schema.optional(GoogleDiscoveryFetchCredentials), - namespace: Schema.optional(Schema.String), - auth: GoogleDiscoveryAuth, -}); -export type GoogleDiscoveryProbeInput = typeof GoogleDiscoveryProbeInputSchema.Type; -export type GoogleDiscoveryAddSourceInput = typeof GoogleDiscoveryAddSourceInputSchema.Type; - -const GoogleDiscoveryAddSourceOutputSchema = Schema.Struct({ - namespace: Schema.String, - source: Schema.Struct({ - id: Schema.String, - scope: Schema.String, - }), - toolCount: Schema.Number, -}); - -const GoogleDiscoveryGetSourceInputSchema = Schema.Struct({ - namespace: Schema.String, - scope: Schema.String, -}); - -const GoogleDiscoveryGetSourceOutputSchema = Schema.Struct({ - source: Schema.NullOr(Schema.Unknown), -}); - -const GoogleDiscoveryConfigureInputSchema = Schema.Struct({ - name: Schema.optional(Schema.String), - auth: Schema.optional(GoogleDiscoveryAuth), -}); -const GoogleDiscoveryConfigureSourceInputSchema = Schema.Struct({ - source: Schema.Struct({ - id: Schema.String, - scope: Schema.String, - }), - ...GoogleDiscoveryConfigureInputSchema.fields, -}); -const GoogleDiscoveryConfigureSourceOutputSchema = Schema.Struct({ - configured: Schema.Boolean, -}); - -const schemaToStaticToolSchema = (schema: Schema.Decoder): StaticToolSchema => - Schema.toStandardSchemaV1(Schema.toStandardJSONSchemaV1(schema) as never) as StaticToolSchema< - A, - I - >; - -const GoogleDiscoveryProbeInputStandardSchema = schemaToStaticToolSchema( - GoogleDiscoveryProbeInputSchema, -); -const GoogleDiscoveryProbeOutputStandardSchema = schemaToStaticToolSchema( - GoogleDiscoveryProbeOutputSchema, -); -const GoogleDiscoveryAddSourceInputStandardSchema = schemaToStaticToolSchema( - GoogleDiscoveryStaticAddSourceInputSchema, -); -const GoogleDiscoveryAddSourceOutputStandardSchema = schemaToStaticToolSchema( - GoogleDiscoveryAddSourceOutputSchema, -); -const GoogleDiscoveryGetSourceInputStandardSchema = schemaToStaticToolSchema( - GoogleDiscoveryGetSourceInputSchema, -); -const GoogleDiscoveryGetSourceOutputStandardSchema = schemaToStaticToolSchema( - GoogleDiscoveryGetSourceOutputSchema, -); -const GoogleDiscoveryConfigureSourceInputStandardSchema = schemaToStaticToolSchema( - GoogleDiscoveryConfigureSourceInputSchema, -); -const GoogleDiscoveryConfigureSourceOutputStandardSchema = schemaToStaticToolSchema( - GoogleDiscoveryConfigureSourceOutputSchema, -); - -const resolveStaticScopeInput = ( - ctx: { readonly scopes: readonly { readonly id: ScopeId; readonly name: string }[] }, - value: string, -): string => - String( - ctx.scopes.find((scope) => scope.name === value || String(scope.id) === value)?.id ?? value, - ); - -/** - * Errors any Google Discovery extension method may surface. - */ -export type GoogleDiscoveryExtensionFailure = - | GoogleDiscoveryParseError - | GoogleDiscoverySourceError - | StorageFailure; - -// --------------------------------------------------------------------------- -// URL normalization + slug helpers (unchanged) -// --------------------------------------------------------------------------- - -const DISCOVERY_SERVICE_HOST = "https://www.googleapis.com/discovery/v1/apis"; -const decodeString = Schema.decodeUnknownSync(Schema.String); -const isGoogleDiscoverySourceError = (error: unknown): error is GoogleDiscoverySourceError => - Predicate.isTagged("GoogleDiscoverySourceError")(error); - -const normalizeDiscoveryUrl = (discoveryUrl: string): string => { - const trimmed = discoveryUrl.trim(); - if (trimmed.length === 0) 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; - const host = parsed.hostname.toLowerCase(); - if (!host.endsWith(".googleapis.com")) return trimmed; - const rawService = host.slice(0, -".googleapis.com".length); - const service = - rawService === "calendar-json" - ? "calendar" - : rawService.endsWith("-json") - ? rawService.slice(0, -5) - : rawService; - if (!service) return trimmed; - return `${DISCOVERY_SERVICE_HOST}/${service}/${version}/rest`; -}; - -const resolveGoogleDiscoveryCredentials = ( - credentials: GoogleDiscoveryFetchCredentials | undefined, - ctx: PluginCtx, -): Effect.Effect< - { headers?: Record; queryParams?: Record } | undefined, - GoogleDiscoverySourceError -> => - Effect.gen(function* () { - if (!credentials) return undefined; - const headers = yield* resolveSecretBackedMap({ - values: credentials.headers, - getSecret: ctx.secrets.get, - onMissing: (name) => - 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) => - isGoogleDiscoverySourceError(err) - ? err - : new GoogleDiscoverySourceError({ message: "Secret resolution failed" }), - ), - ); - const queryParams = yield* resolveSecretBackedMap({ - values: credentials.queryParams, - getSecret: ctx.secrets.get, - onMissing: (name) => - 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) => - isGoogleDiscoverySourceError(err) - ? err - : new GoogleDiscoverySourceError({ message: "Secret resolution failed" }), - ), - ); - return { - ...(headers ? { headers } : {}), - ...(queryParams ? { queryParams } : {}), - }; - }); - -const fetchDiscoveryDocument = ( - discoveryUrl: string, - credentials?: { - readonly headers?: Record; - readonly queryParams?: Record; - }, -) => - Effect.gen(function* () { - const response = yield* Effect.tryPromise({ - try: () => { - const url = new URL(normalizeDiscoveryUrl(discoveryUrl)); - 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), - }); - }, - 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: "Google Discovery response body read failed", - }), - }); - }); - -const normalizeSlug = (value: string): string => - value - .toLowerCase() - .replace(/[^a-z0-9]+/g, "_") - .replace(/^_+|_+$/g, ""); - -const deriveNamespace = (input: { name: string; service: string; version: string }): string => - normalizeSlug( - input.name || `google_${input.service}_${input.version.replace(/[^a-zA-Z0-9]+/g, "_")}`, - ) || `google_${input.service}`; - -// Connection refresh state is owned by the canonical `"oauth2"` -// ConnectionProvider registered by core. `ctx.oauth.start` stamps the -// Google-specific token endpoint + scopes onto the connection's -// providerState at mint time — no plugin-owned schema needed. - -// --------------------------------------------------------------------------- -// Register a parsed manifest against the executor core + plugin storage. -// Runs inside a transaction. -// --------------------------------------------------------------------------- - -const registerManifest = ( - ctx: PluginCtx, - namespace: string, - scope: string, - manifest: GoogleDiscoveryManifest, - sourceData: GoogleDiscoveryStoredSourceData, -) => - Effect.gen(function* () { - yield* ctx.storage.removeBindingsBySource(namespace, scope); - yield* ctx.core.sources.unregister({ id: namespace, targetScope: scope }).pipe(Effect.ignore); - - yield* ctx.core.sources.register({ - id: namespace, - scope, - kind: "googleDiscovery", - name: sourceData.name, - url: sourceData.rootUrl, - canRemove: true, - canRefresh: true, - canEdit: true, - tools: manifest.methods.map((method: GoogleDiscoveryManifestMethod) => ({ - name: method.toolPath, - description: Option.getOrElse( - method.description, - () => `${method.binding.method.toUpperCase()} ${method.binding.pathTemplate}`, - ), - inputSchema: Option.getOrUndefined(method.inputSchema), - outputSchema: Option.getOrUndefined(method.outputSchema), - })), - }); - - if (Object.keys(manifest.schemaDefinitions).length > 0) { - yield* ctx.core.definitions.register({ - sourceId: namespace, - scope, - definitions: manifest.schemaDefinitions, - }); - } - - yield* Effect.forEach( - manifest.methods, - (method) => - ctx.storage.putBinding(`${namespace}.${method.toolPath}`, namespace, scope, method.binding), - { discard: true }, - ); - - yield* ctx.storage.putSource({ - namespace, - scope, - name: sourceData.name, - config: sourceData, - }); - - return manifest.methods.length; - }); - -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 = GoogleDiscoveryStoredSourceDataSchema.make({ - 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({ id: namespace, targetScope: scope }) - .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 type GoogleDiscoveryPluginExtension = ReturnType; - -// --------------------------------------------------------------------------- -// Plugin -// --------------------------------------------------------------------------- - -export const googleDiscoveryPlugin = definePlugin(() => ({ - id: "googleDiscovery" as const, - packageName: "@executor-js/plugin-google-discovery", - sourcePresets: googleDiscoveryPresets, - schema: googleDiscoverySchema, - storage: (deps) => makeGoogleDiscoveryStore(deps), - - extension: makeGoogleDiscoveryPluginExtension, - - staticSources: (self) => [ - { - id: "googleDiscovery", - kind: "executor", - name: "Google Discovery", - tools: [ - tool({ - name: "probeDiscovery", - description: - "Preview a Google Discovery document before adding it as a source. Use this to inspect available operations and OAuth scopes. Do not collect Google OAuth client secrets in chat; create them with `executor.coreTools.secrets.create`, then start sign-in with `executor.coreTools.oauth.start`.", - inputSchema: GoogleDiscoveryProbeInputStandardSchema, - outputSchema: GoogleDiscoveryProbeOutputStandardSchema, - execute: (input) => Effect.map(self.probeDiscovery(input), ToolResult.ok), - }), - tool({ - name: "addSource", - description: - 'Add a Google Discovery source and register its operations as tools. Executor chooses the source install scope (local scope locally, organization scope in cloud) and returns it as `source`. Recommended flow: call `probeDiscovery`, create any OAuth client id/client secret values through `secrets.create` at the user\'s chosen credential scope, call `oauth.start` with `credentialScope` set to the user\'s chosen personal or organization credential scope for OAuth sources, then pass `{kind:"oauth2", connectionId, clientIdSecretId, clientSecretSecretId, scopes}` or `{kind:"none"}` here.', - annotations: { - requiresApproval: true, - approvalDescription: "Add a Google Discovery source", - }, - inputSchema: GoogleDiscoveryAddSourceInputStandardSchema, - outputSchema: GoogleDiscoveryAddSourceOutputStandardSchema, - execute: (input, { ctx }) => { - const args = input as typeof GoogleDiscoveryStaticAddSourceInputSchema.Type; - const sourceScope = defaultSourceInstallScopeId(ctx.scopes); - if (sourceScope === null) { - return Effect.succeed( - ToolResult.fail({ - code: "source_scope_unavailable", - message: - "Cannot add a Google Discovery source because this executor has no source install scope.", - }), - ); - } - return Effect.map(self.addSource({ ...args, scope: sourceScope }), (result) => - ToolResult.ok({ - ...result, - source: { id: result.namespace, scope: sourceScope }, - }), - ); - }, - }), - tool({ - name: "getSource", - description: - "Inspect an existing Google Discovery source, including discovery URL, service metadata, auth mode, OAuth scopes, connection id, and credential slots. Use this before repairing an existing source with `googleDiscovery.configureSource`, `secrets.create`, or `oauth.start`.", - inputSchema: GoogleDiscoveryGetSourceInputStandardSchema, - outputSchema: GoogleDiscoveryGetSourceOutputStandardSchema, - execute: (input, { ctx }) => { - const args = input as typeof GoogleDiscoveryGetSourceInputSchema.Type; - return Effect.map( - self.getSource(args.namespace, resolveStaticScopeInput(ctx, args.scope)), - (source) => ToolResult.ok({ source }), - ); - }, - }), - tool({ - name: "configureSource", - description: - "Configure an existing Google Discovery source with concrete fields. Use `source` returned by `googleDiscovery.addSource` or `sources.list`. For OAuth, call `oauth.start` with the target `credentialScope` first, then pass the returned connection id and client secret ids through `auth`.", - annotations: { - requiresApproval: true, - approvalDescription: "Configure a Google Discovery source", - }, - inputSchema: GoogleDiscoveryConfigureSourceInputStandardSchema, - outputSchema: GoogleDiscoveryConfigureSourceOutputStandardSchema, - execute: (input, { ctx }) => { - const { source, ...config } = - input as typeof GoogleDiscoveryConfigureSourceInputSchema.Type; - const sourceScope = resolveStaticScopeInput(ctx, source.scope); - return Effect.as( - self.updateSource(source.id, sourceScope, config), - ToolResult.ok({ configured: true }), - ); - }, - }), - ], - }, - ], - - sourceConfigure: { - type: "googleDiscovery", - schema: GoogleDiscoveryConfigureInputSchema, - configure: ({ ctx, sourceId, sourceScope, config }) => - makeGoogleDiscoveryPluginExtension(ctx as PluginCtx).updateSource( - sourceId, - sourceScope, - config as typeof GoogleDiscoveryConfigureInputSchema.Type, - ), - }, - - invokeTool: ({ ctx, toolRow, args }) => - Effect.gen(function* () { - const result = yield* invokeGoogleDiscoveryTool({ - ctx: ctx as PluginCtx, - toolId: toolRow.id, - toolScope: decodeString(toolRow.scope_id), - args, - }); - const ok = result.status >= 200 && result.status < 300; - if (!ok) { - return ToolResult.fail({ - code: "upstream_http_error", - status: result.status, - message: googleExtractUpstreamMessage(result.error, result.status), - details: result.error, - }); - } - return ToolResult.ok({ - status: result.status, - headers: result.headers, - data: result.data, - }); - }), - - resolveAnnotations: ({ ctx, sourceId, toolRows }) => - Effect.gen(function* () { - const typedCtx = ctx as PluginCtx; - const scopes = new Set(); - for (const row of toolRows) scopes.add(decodeString(row.scope_id)); - const byScope = new Map>(); - for (const scope of scopes) { - const bindings = yield* typedCtx.storage.getBindingsForSource(sourceId, scope); - byScope.set(scope, bindings); - } - const out: Record = {}; - for (const row of toolRows) { - const binding = byScope.get(decodeString(row.scope_id))?.get(row.id); - if (binding) { - out[row.id] = annotationsForOperation(binding.method, binding.pathTemplate); - } - } - return out; - }), - - removeSource: ({ ctx, sourceId, scope }) => - Effect.gen(function* () { - const typedCtx = ctx as PluginCtx; - yield* typedCtx.storage.removeBindingsBySource(sourceId, scope); - yield* typedCtx.storage.removeSource(sourceId, scope); - }), - - // Aggregate usages across the auth columns and the credential child - // tables. Each is one indexed SELECT in the store; the merge plus a - // single source-name JOIN happens here. - usagesForSecret: ({ ctx, args }) => - Effect.gen(function* () { - const typedCtx = ctx as PluginCtx; - const sources = yield* typedCtx.storage.findSourcesBySecret(args.secretId); - const childRows = yield* typedCtx.storage.findCredentialRowsBySecret(args.secretId); - const sourceKeys = new Set(); - for (const s of sources) sourceKeys.add(`${s.scope_id}:${s.namespace}`); - for (const r of childRows) sourceKeys.add(`${r.scope_id}:${r.source_id}`); - const names = yield* typedCtx.storage.lookupSourceNames([...sourceKeys]); - - const out: Usage[] = []; - for (const s of sources) { - out.push( - Usage.make({ - pluginId: "google-discovery", - scopeId: ScopeId.make(s.scope_id), - ownerKind: "google-discovery-source", - ownerId: s.namespace, - ownerName: names.get(`${s.scope_id}:${s.namespace}`) ?? s.name, - slot: s.slot, - }), - ); - } - for (const r of childRows) { - out.push( - Usage.make({ - pluginId: "google-discovery", - scopeId: ScopeId.make(r.scope_id), - ownerKind: `google-discovery-source-${r.kind.replace(/_/g, "-")}`, - ownerId: r.source_id, - ownerName: names.get(`${r.scope_id}:${r.source_id}`) ?? null, - slot: `${r.kind}:${r.name}`, - }), - ); - } - return out; - }), - - usagesForConnection: ({ ctx, args }) => - Effect.gen(function* () { - const typedCtx = ctx as PluginCtx; - const sources = yield* typedCtx.storage.findSourcesByConnection(args.connectionId); - return sources.map((s) => - Usage.make({ - pluginId: "google-discovery", - scopeId: ScopeId.make(s.scope_id), - ownerKind: "google-discovery-source", - ownerId: s.namespace, - ownerName: s.name, - slot: s.slot, - }), - ); - }), - - detect: ({ url }) => - Effect.gen(function* () { - const trimmed = url.trim(); - if (!trimmed) return null; - const parsed = yield* Effect.try({ - try: () => new URL(trimmed), - catch: (error) => error, - }).pipe(Effect.option); - if (Option.isNone(parsed)) return null; - - const isGoogleUrl = trimmed.includes("googleapis.com"); - const isDiscoveryPath = trimmed.includes("/discovery/") || trimmed.includes("$discovery"); - if (!isGoogleUrl && !isDiscoveryPath) return null; - - const discoveryText = yield* fetchDiscoveryDocument(trimmed).pipe( - Effect.catch(() => Effect.succeed(null)), - ); - if (!discoveryText) return null; - - const manifest = yield* extractGoogleDiscoveryManifest(discoveryText).pipe( - Effect.catch(() => Effect.succeed(null)), - ); - if (!manifest) return null; - - const name = Option.getOrElse( - manifest.title, - () => `${manifest.service} ${manifest.version}`, - ); - - return SourceDetectionResult.make({ - kind: "googleDiscovery", - confidence: "high", - endpoint: trimmed, - name, - namespace: deriveNamespace({ - name, - service: manifest.service, - version: manifest.version, - }), - }); - }), - - refreshSource: ({ ctx, sourceId, scope }) => - Effect.gen(function* () { - const typedCtx = ctx as PluginCtx; - const existing = yield* typedCtx.storage.getSource(sourceId, scope); - if (!existing) return; - const credentials = yield* resolveGoogleDiscoveryCredentials( - existing.config.credentials, - typedCtx, - ); - const text = yield* fetchDiscoveryDocument(existing.config.discoveryUrl, credentials); - const manifest = yield* extractGoogleDiscoveryManifest(text); - const next = GoogleDiscoveryStoredSourceDataSchema.make({ - ...existing.config, - service: manifest.service, - version: manifest.version, - rootUrl: manifest.rootUrl, - servicePath: manifest.servicePath, - }); - yield* registerManifest(typedCtx, sourceId, scope, manifest, next); - }), - - // Connection refresh is owned by the canonical `"oauth2"` - // ConnectionProvider registered by core — no plugin-specific handler - // needed. The Google-specific `GOOGLE_TOKEN_URL` lives on the - // connection's providerState (stamped at `ctx.oauth.start` time with - // the `authorization-code` strategy's tokenEndpoint), so refresh - // reaches Google through the unified code path. - - // HTTP transport (routes/handlers/extensionService) is layered on by - // the api-aware factory in `@executor-js/plugin-google-discovery/api`. - // Hosts that want the HTTP surface import the plugin from there; - // SDK-only consumers stay on this entry and avoid the server-only deps. -})); diff --git a/packages/plugins/google-discovery/src/sdk/stored-source.ts b/packages/plugins/google-discovery/src/sdk/stored-source.ts deleted file mode 100644 index 74d8a7042..000000000 --- a/packages/plugins/google-discovery/src/sdk/stored-source.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Schema } from "effect"; - -import { GoogleDiscoveryStoredSourceData } from "./types"; - -// --------------------------------------------------------------------------- -// Stored source — the shape persisted by the binding store and exposed -// via the getSource HTTP endpoint. -// --------------------------------------------------------------------------- - -export const GoogleDiscoveryStoredSourceSchema = Schema.Struct({ - namespace: Schema.String, - name: Schema.String, - config: GoogleDiscoveryStoredSourceData, -}).annotate({ identifier: "GoogleDiscoveryStoredSource" }); -export type GoogleDiscoveryStoredSourceSchema = typeof GoogleDiscoveryStoredSourceSchema.Type; - -export type GoogleDiscoveryStoredSourceSchemaType = typeof GoogleDiscoveryStoredSourceSchema.Type; diff --git a/packages/plugins/google-discovery/src/sdk/types.ts b/packages/plugins/google-discovery/src/sdk/types.ts deleted file mode 100644 index 41825bdc5..000000000 --- a/packages/plugins/google-discovery/src/sdk/types.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { Schema } from "effect"; -import { SecretBackedValue } from "@executor-js/sdk/shared"; - -export const GoogleDiscoveryHttpMethod = Schema.Literals([ - "get", - "put", - "post", - "delete", - "patch", - "head", - "options", -]); -export type GoogleDiscoveryHttpMethod = typeof GoogleDiscoveryHttpMethod.Type; - -export const GoogleDiscoveryParameterLocation = Schema.Literals(["path", "query", "header"]); -export type GoogleDiscoveryParameterLocation = typeof GoogleDiscoveryParameterLocation.Type; - -export const GoogleDiscoveryParameter = Schema.Struct({ - name: Schema.String, - location: GoogleDiscoveryParameterLocation, - required: Schema.Boolean, - repeated: Schema.Boolean, - description: Schema.OptionFromOptional(Schema.String), - schema: Schema.OptionFromOptional(Schema.Unknown), -}); -export type GoogleDiscoveryParameter = typeof GoogleDiscoveryParameter.Type; - -export const GoogleDiscoveryMethodBinding = Schema.Struct({ - method: GoogleDiscoveryHttpMethod, - pathTemplate: Schema.String, - parameters: Schema.Array(GoogleDiscoveryParameter), - hasBody: Schema.Boolean, -}); -export type GoogleDiscoveryMethodBinding = typeof GoogleDiscoveryMethodBinding.Type; - -export const GoogleDiscoveryManifestMethod = Schema.Struct({ - toolPath: Schema.String, - description: Schema.OptionFromOptional(Schema.String), - binding: GoogleDiscoveryMethodBinding, - inputSchema: Schema.OptionFromOptional(Schema.Unknown), - outputSchema: Schema.OptionFromOptional(Schema.Unknown), - scopes: Schema.Array(Schema.String), -}); -export type GoogleDiscoveryManifestMethod = typeof GoogleDiscoveryManifestMethod.Type; - -export const GoogleDiscoveryManifest = Schema.Struct({ - title: Schema.OptionFromOptional(Schema.String), - service: Schema.String, - version: Schema.String, - rootUrl: Schema.String, - servicePath: Schema.String, - oauthScopes: Schema.OptionFromOptional(Schema.Record(Schema.String, Schema.String)), - schemaDefinitions: Schema.Record(Schema.String, Schema.Unknown), - methods: Schema.Array(GoogleDiscoveryManifestMethod), -}); -export type GoogleDiscoveryManifest = typeof GoogleDiscoveryManifest.Type; - -// --------------------------------------------------------------------------- -// Auth — a source either runs unauthenticated or is backed by a Connection. -// -// The source owns the API-level OAuth config (client credential secret -// ids + scopes) so a stale sign-in can always be re-run from the source -// detail page without needing the prior Connection to still exist. The -// Connection owns live tokens + refresh state (and caches the same -// config on `providerState` for the refresh path). This small -// duplication keeps reconnect fully source-driven. -// --------------------------------------------------------------------------- - -export const GoogleDiscoveryAuth = Schema.Union([ - Schema.Struct({ - kind: Schema.Literal("none"), - }), - Schema.Struct({ - kind: Schema.Literal("oauth2"), - /** Connection id; resolve via `ctx.connections.accessToken(id)`. - * Rewritten on sign-in to point at the freshly minted connection. */ - connectionId: Schema.String, - /** Secret id holding the OAuth client_id. */ - clientIdSecretId: Schema.String, - /** Secret id holding the OAuth client_secret. Null for public clients. */ - clientSecretSecretId: Schema.NullOr(Schema.String), - /** Scopes requested on sign-in. */ - scopes: Schema.Array(Schema.String), - }), -]); -export type GoogleDiscoveryAuth = typeof GoogleDiscoveryAuth.Type; - -export const GoogleDiscoveryCredentialValue = SecretBackedValue; -export type GoogleDiscoveryCredentialValue = typeof GoogleDiscoveryCredentialValue.Type; - -export const GoogleDiscoveryFetchCredentials = Schema.Struct({ - headers: Schema.optional(Schema.Record(Schema.String, GoogleDiscoveryCredentialValue)), - queryParams: Schema.optional(Schema.Record(Schema.String, GoogleDiscoveryCredentialValue)), -}); -export type GoogleDiscoveryFetchCredentials = typeof GoogleDiscoveryFetchCredentials.Type; - -export const GoogleDiscoveryStoredSourceData = Schema.Struct({ - name: Schema.String, - discoveryUrl: Schema.String, - credentials: Schema.optional(GoogleDiscoveryFetchCredentials), - service: Schema.String, - version: Schema.String, - rootUrl: Schema.String, - servicePath: Schema.String, - auth: GoogleDiscoveryAuth, -}); -export type GoogleDiscoveryStoredSourceData = typeof GoogleDiscoveryStoredSourceData.Type; - -export const GoogleDiscoveryInvocationResult = Schema.Struct({ - status: Schema.Number, - headers: Schema.Record(Schema.String, Schema.String), - data: Schema.NullOr(Schema.Unknown), - error: Schema.NullOr(Schema.Unknown), -}); -export type GoogleDiscoveryInvocationResult = typeof GoogleDiscoveryInvocationResult.Type; - -export interface GoogleDiscoverySourceMeta { - readonly namespace: string; - readonly name: string; -} diff --git a/packages/plugins/google-discovery/tsconfig.json b/packages/plugins/google-discovery/tsconfig.json deleted file mode 100644 index 1504bed72..000000000 --- a/packages/plugins/google-discovery/tsconfig.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "Bundler", - "strict": true, - "skipLibCheck": true, - "lib": ["ES2022", "DOM"], - "types": ["bun-types", "node"], - "noUnusedLocals": true, - "noImplicitOverride": true, - "jsx": "react-jsx", - "plugins": [ - { - "name": "@effect/language-service", - "ignoreEffectSuggestionsInTscExitCode": true, - "ignoreEffectWarningsInTscExitCode": true, - "diagnosticSeverity": {} - } - ] - }, - "include": ["src/**/*.ts", "src/**/*.tsx"] -} diff --git a/packages/plugins/google-discovery/tsup.config.ts b/packages/plugins/google-discovery/tsup.config.ts deleted file mode 100644 index 0b6a796fe..000000000 --- a/packages/plugins/google-discovery/tsup.config.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { defineConfig } from "tsup"; - -export default defineConfig({ - entry: { - index: "src/promise.ts", - core: "src/sdk/index.ts", - client: "src/react/plugin-client.tsx", - }, - format: ["esm"], - dts: false, - sourcemap: true, - clean: true, - external: [/^@executor-js\//, /^effect/, /^@effect\//], -}); diff --git a/packages/plugins/google-discovery/vitest.config.ts b/packages/plugins/google-discovery/vitest.config.ts deleted file mode 100644 index 0d127f82c..000000000 --- a/packages/plugins/google-discovery/vitest.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - include: ["src/**/*.test.ts"], - testTimeout: 15_000, - }, -}); diff --git a/packages/plugins/openapi/src/api/group.ts b/packages/plugins/openapi/src/api/group.ts index 62bf31c77..46d38b126 100644 --- a/packages/plugins/openapi/src/api/group.ts +++ b/packages/plugins/openapi/src/api/group.ts @@ -36,6 +36,7 @@ const SourceParams = { const OpenApiSpecInputPayload = Schema.Union([ Schema.Struct({ kind: Schema.Literal("url"), url: Schema.String }), Schema.Struct({ kind: Schema.Literal("blob"), value: Schema.String }), + Schema.Struct({ kind: Schema.Literal("googleDiscovery"), url: Schema.String }), ]); const PreviewSpecFetchCredentialsPayload = Schema.Struct({ diff --git a/packages/plugins/openapi/src/react/AddOpenApiSource.tsx b/packages/plugins/openapi/src/react/AddOpenApiSource.tsx index f78f37376..27387eb83 100644 --- a/packages/plugins/openapi/src/react/AddOpenApiSource.tsx +++ b/packages/plugins/openapi/src/react/AddOpenApiSource.tsx @@ -140,10 +140,21 @@ const specInputForAdd = (input: string) => { }), ); return Exit.isSuccess(parsed) - ? { kind: "url" as const, url: value } + ? isGoogleDiscoveryUrl(value) + ? { kind: "googleDiscovery" as const, url: value } + : { kind: "url" as const, url: value } : { kind: "blob" as const, value }; }; +const isGoogleDiscoveryUrl = (url: string): boolean => { + const trimmed = url.trim(); + if (!URL.canParse(trimmed)) return false; + const parsed = new URL(trimmed); + const host = parsed.hostname.toLowerCase(); + if (!host.endsWith("googleapis.com")) return false; + return parsed.pathname.includes("/discovery/") || parsed.pathname.includes("$discovery"); +}; + type StrategySelection = | { readonly kind: "none" } | { readonly kind: "custom" } diff --git a/packages/plugins/openapi/src/sdk/definitions.ts b/packages/plugins/openapi/src/sdk/definitions.ts index 7f9a54cca..e35ebe252 100644 --- a/packages/plugins/openapi/src/sdk/definitions.ts +++ b/packages/plugins/openapi/src/sdk/definitions.ts @@ -6,6 +6,8 @@ * can render with proper nesting. */ +import { Option } from "effect"; + import type { ExtractedOperation } from "./types"; // --------------------------------------------------------------------------- @@ -220,6 +222,29 @@ export const compileToolDefinitions = ( ): ToolDefinition[] => { const raw = operations.map((op, index) => { const operationId = op.operationId; + const explicitToolPath = Option.getOrUndefined(op.toolPath); + if (explicitToolPath) { + const [group = "root", ...leafParts] = explicitToolPath.split(".").filter(Boolean); + const leaf = leafParts.join(".") || group; + const versionSegment = deriveVersionSegment(op.pathTemplate); + const operationHash = stableHash({ + method: op.method, + path: op.pathTemplate, + operationId, + }); + + return { + toolPath: explicitToolPath, + group, + leaf, + versionSegment, + method: op.method, + operationHash, + operationIndex: index, + operation: op, + }; + } + const group = normalizeGroupSegment(op.tags[0]) ?? derivePathGroup(op.pathTemplate); const leaf = deriveLeaf(operationId, op.method, op.pathTemplate, group); const versionSegment = deriveVersionSegment(op.pathTemplate); diff --git a/packages/plugins/openapi/src/sdk/extract.ts b/packages/plugins/openapi/src/sdk/extract.ts index 191ba0466..fbdb76eec 100644 --- a/packages/plugins/openapi/src/sdk/extract.ts +++ b/packages/plugins/openapi/src/sdk/extract.ts @@ -222,6 +222,11 @@ const deriveOperationId = ( (`${method}_${pathTemplate.replace(/[^a-zA-Z0-9]+/g, "_")}`.replace(/^_+|_+$/g, "") || `${method}_operation`); +const explicitToolPath = (operation: OperationObject): string | undefined => { + const value = (operation as Record)["x-executor-toolPath"]; + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +}; + // --------------------------------------------------------------------------- // Server extraction // --------------------------------------------------------------------------- @@ -293,6 +298,7 @@ export const extract = Effect.fn("OpenApi.extract")(function* (doc: ParsedDocume operations.push( ExtractedOperation.make({ operationId: OperationId.make(deriveOperationId(method, pathTemplate, operation)), + toolPath: Option.fromNullishOr(explicitToolPath(operation)), method, pathTemplate, summary: Option.fromNullishOr(operation.summary), diff --git a/packages/plugins/openapi/src/sdk/google-discovery.test.ts b/packages/plugins/openapi/src/sdk/google-discovery.test.ts new file mode 100644 index 000000000..526b7386e --- /dev/null +++ b/packages/plugins/openapi/src/sdk/google-discovery.test.ts @@ -0,0 +1,104 @@ +import { expect, it } from "@effect/vitest"; +import { Effect, Schema } from "effect"; + +import { convertGoogleDiscoveryToOpenApi } from "./google-discovery"; + +const ConvertedOperation = Schema.Struct({ + operationId: Schema.String, + "x-executor-toolPath": Schema.String, + parameters: Schema.Array( + Schema.Struct({ + name: Schema.String, + in: Schema.String, + required: Schema.Boolean, + style: Schema.optional(Schema.String), + explode: Schema.optional(Schema.Boolean), + }), + ), + security: Schema.optional( + Schema.Array(Schema.Record(Schema.String, Schema.Array(Schema.String))), + ), + "x-google-scopes": Schema.Array(Schema.String), +}); + +const ConvertedSpec = Schema.Struct({ + openapi: Schema.String, + servers: Schema.Array(Schema.Struct({ url: Schema.String })), + paths: Schema.Record(Schema.String, Schema.Record(Schema.String, ConvertedOperation)), +}); + +const decodeConvertedSpec = Schema.decodeUnknownSync(Schema.fromJsonString(ConvertedSpec)); + +it.effect("converts Google Discovery documents into Executor-preserving OpenAPI 3 specs", () => + Effect.gen(function* () { + const result = yield* convertGoogleDiscoveryToOpenApi({ + discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest", + // @effect-diagnostics-next-line preferSchemaOverJson:off + documentText: JSON.stringify({ + name: "gmail", + version: "v1", + title: "Gmail API", + rootUrl: "https://gmail.googleapis.com/", + servicePath: "", + auth: { + oauth2: { + scopes: { + "https://www.googleapis.com/auth/gmail.metadata": { + description: "Read metadata", + }, + }, + }, + }, + resources: { + users: { + resources: { + messages: { + methods: { + list: { + id: "gmail.users.messages.list", + httpMethod: "GET", + path: "gmail/v1/users/{userId}/messages", + scopes: ["https://www.googleapis.com/auth/gmail.metadata"], + parameters: { + userId: { + location: "path", + required: true, + type: "string", + }, + metadataHeaders: { + location: "query", + repeated: true, + type: "string", + }, + }, + }, + }, + }, + }, + }, + }, + }), + }); + + const spec = decodeConvertedSpec(result.specText); + const operation = spec.paths["/gmail/v1/users/{userId}/messages"]?.get; + expect(spec.openapi).toBe("3.1.0"); + expect(spec.servers).toEqual([{ url: "https://gmail.googleapis.com/" }]); + expect(operation).toMatchObject({ + operationId: "users.messages.list", + "x-executor-toolPath": "users.messages.list", + "x-google-scopes": ["https://www.googleapis.com/auth/gmail.metadata"], + }); + expect(operation?.security).toEqual([ + { googleOAuth2: ["https://www.googleapis.com/auth/gmail.metadata"] }, + ]); + expect(operation?.parameters).toContainEqual( + expect.objectContaining({ + name: "metadataHeaders", + in: "query", + style: "form", + explode: true, + }), + ); + }), +); diff --git a/packages/plugins/openapi/src/sdk/google-discovery.ts b/packages/plugins/openapi/src/sdk/google-discovery.ts new file mode 100644 index 000000000..dd1331420 --- /dev/null +++ b/packages/plugins/openapi/src/sdk/google-discovery.ts @@ -0,0 +1,431 @@ +// Converts Google Discovery documents directly into OpenAPI 3.x. Public +// Discovery converters currently target Swagger 2.0 or a broad conversion +// pipeline; this adapter emits the shape Executor parses while preserving +// Executor-specific tool ids and query semantics. +import { Effect, Option, Schema, SchemaGetter } from "effect"; +import { HttpClient, HttpClientRequest } from "effect/unstable/http"; + +import { OpenApiParseError } from "./errors"; +import { + oauth2ClientIdSlot, + oauth2ClientSecretSlot, + oauth2ConnectionSlot, +} from "./source-contracts"; +import type { OAuth2SourceConfig } from "./types"; +import type { SpecFetchCredentials } from "./parse"; + +const DISCOVERY_SERVICE_HOST = "https://www.googleapis.com/discovery/v1/apis"; + +const TextOption = Schema.OptionFromOptional(Schema.Trim).pipe( + Schema.decode({ + decode: SchemaGetter.transform((value) => Option.filter(value, (text) => text.length > 0)), + encode: SchemaGetter.transform((value) => value), + }), + Schema.withDecodingDefaultType(Effect.succeed(Option.none())), +); +const TextArray = Schema.optional(Schema.Array(Schema.String)).pipe( + Schema.withDecodingDefaultType(Effect.succeed([] as string[])), +); +const UnknownRecord = Schema.Record(Schema.String, Schema.Unknown); +const UnknownRecordWithDefault = Schema.optional(UnknownRecord).pipe( + Schema.withDecodingDefaultType(Effect.succeed({})), +); + +const DiscoveryParameter = Schema.Struct({ + type: Schema.optional(Schema.String), + description: TextOption, + properties: UnknownRecordWithDefault, + items: Schema.optional(Schema.Unknown), + additionalProperties: Schema.optional(Schema.Union([Schema.Boolean, Schema.Unknown])), + enum: TextArray, + format: Schema.optional(Schema.String), + readOnly: Schema.optional(Schema.Boolean), + default: Schema.optional(Schema.Union([Schema.String, Schema.Number, Schema.Boolean])), + $ref: Schema.optional(Schema.String), + location: Schema.optional(Schema.Literals(["path", "query", "header"])), + required: Schema.optional(Schema.Boolean), + repeated: Schema.optional(Schema.Boolean), +}); +type DiscoveryParameter = typeof DiscoveryParameter.Type; + +const DiscoveryRef = Schema.Struct({ + $ref: Schema.optional(Schema.String), +}); + +const DiscoveryMethod = Schema.Struct({ + id: TextOption, + description: TextOption, + httpMethod: Schema.optional(Schema.String), + path: TextOption, + parameters: UnknownRecordWithDefault, + request: Schema.optional(DiscoveryRef), + response: Schema.optional(DiscoveryRef), + scopes: TextArray, +}); +type DiscoveryMethod = typeof DiscoveryMethod.Type; + +const DiscoveryResource = Schema.Struct({ + methods: UnknownRecordWithDefault, + resources: UnknownRecordWithDefault, +}); + +const DiscoveryDocument = Schema.Struct({ + name: TextOption, + version: TextOption, + title: TextOption, + rootUrl: TextOption, + servicePath: Schema.optional(Schema.Trim).pipe( + Schema.withDecodingDefaultType(Effect.succeed("")), + ), + parameters: UnknownRecordWithDefault, + methods: UnknownRecordWithDefault, + resources: UnknownRecordWithDefault, + schemas: UnknownRecordWithDefault, + auth: Schema.optional( + Schema.Struct({ + oauth2: Schema.optional( + Schema.Struct({ + scopes: Schema.optional( + Schema.Record( + Schema.String, + Schema.Struct({ + description: TextOption, + }), + ), + ).pipe(Schema.withDecodingDefaultType(Effect.succeed({}))), + }), + ), + }), + ), +}); +type DiscoveryDocument = typeof DiscoveryDocument.Type; + +export interface GoogleDiscoveryOpenApiConversion { + readonly specText: string; + readonly baseUrl: string; + readonly title: string; + readonly service: string; + readonly version: string; + readonly oauth2?: OAuth2SourceConfig; +} + +const decodeDiscoveryDocument = Schema.decodeUnknownSync(DiscoveryDocument); +const decodeDiscoveryParameter = Schema.decodeUnknownSync(DiscoveryParameter); +const decodeDiscoveryMethod = Schema.decodeUnknownSync(DiscoveryMethod); +const decodeDiscoveryResource = Schema.decodeUnknownSync(DiscoveryResource); +const parseJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); + +const normalizeDiscoveryUrl = (discoveryUrl: string): string => { + const trimmed = discoveryUrl.trim(); + 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; + const host = parsed.hostname.toLowerCase(); + if (!host.endsWith(".googleapis.com")) return trimmed; + const rawService = host.slice(0, -".googleapis.com".length); + const service = + rawService === "calendar-json" + ? "calendar" + : rawService.endsWith("-json") + ? rawService.slice(0, -5) + : rawService; + return service ? `${DISCOVERY_SERVICE_HOST}/${service}/${version}/rest` : trimmed; +}; + +export const isGoogleDiscoveryUrl = (url: string): boolean => { + const trimmed = url.trim(); + if (!URL.canParse(trimmed)) return false; + const parsed = new URL(trimmed); + const host = parsed.hostname.toLowerCase(); + if (!host.endsWith("googleapis.com")) return false; + return parsed.pathname.includes("/discovery/") || parsed.pathname.includes("$discovery"); +}; + +export const fetchGoogleDiscoveryDocument = Effect.fn("OpenApi.fetchGoogleDiscoveryDocument")( + function* (discoveryUrl: string, credentials?: SpecFetchCredentials) { + const client = yield* HttpClient.HttpClient; + const requestUrl = new URL(discoveryUrl); + for (const [name, value] of Object.entries(credentials?.queryParams ?? {})) { + requestUrl.searchParams.set(name, value); + } + let request = HttpClientRequest.get(requestUrl.toString()).pipe( + HttpClientRequest.setHeader("Accept", "application/json, */*"), + ); + for (const [name, value] of Object.entries(credentials?.headers ?? {})) { + request = HttpClientRequest.setHeader(request, name, value); + } + const response = yield* client.execute(request).pipe( + Effect.mapError( + () => + new OpenApiParseError({ + message: "Failed to fetch Google Discovery document", + }), + ), + ); + if (response.status < 200 || response.status >= 300) { + return yield* new OpenApiParseError({ + message: `Failed to fetch Google Discovery document: HTTP ${response.status}`, + }); + } + return yield* response.text.pipe( + Effect.mapError( + () => + new OpenApiParseError({ + message: "Failed to read Google Discovery document body", + }), + ), + ); + }, +); + +const schemaRef = (name: string) => `#/$defs/${name}`; + +const discoverySchemaToJsonSchema = (raw: unknown): unknown => { + if (!raw || typeof raw !== "object") return {}; + const schema = raw as Record; + if (typeof schema.$ref === "string") return { $ref: schemaRef(schema.$ref) }; + + const out: Record = {}; + for (const key of ["description", "format", "readOnly", "default", "enum"]) { + if (schema[key] !== undefined) out[key] = schema[key]; + } + + if (schema.type === "array") { + return { ...out, type: "array", items: discoverySchemaToJsonSchema(schema.items) }; + } + + const properties = schema.properties; + if ( + schema.type === "object" || + (properties && typeof properties === "object" && !Array.isArray(properties)) || + schema.additionalProperties !== undefined + ) { + const convertedProperties = + properties && typeof properties === "object" && !Array.isArray(properties) + ? Object.fromEntries( + Object.entries(properties).map(([name, value]) => [ + name, + discoverySchemaToJsonSchema(value), + ]), + ) + : undefined; + return { + ...out, + type: "object", + ...(convertedProperties && Object.keys(convertedProperties).length > 0 + ? { properties: convertedProperties } + : {}), + ...(Array.isArray(schema.required) && schema.required.length > 0 + ? { required: schema.required } + : {}), + ...(schema.additionalProperties === undefined + ? {} + : { + additionalProperties: + typeof schema.additionalProperties === "boolean" + ? schema.additionalProperties + : discoverySchemaToJsonSchema(schema.additionalProperties), + }), + }; + } + + return typeof schema.type === "string" && schema.type !== "any" + ? { ...out, type: schema.type } + : out; +}; + +const parameterSchema = (parameter: DiscoveryParameter): unknown => { + const base = discoverySchemaToJsonSchema(parameter); + return parameter.repeated + ? { + type: "array", + items: base, + } + : base; +}; + +const methodToolPath = (service: string, methodId: string): string => + methodId.startsWith(`${service}.`) ? methodId.slice(service.length + 1) : methodId; + +const collectMethods = (resource: unknown): DiscoveryMethod[] => { + const decoded = decodeDiscoveryResource(resource); + const direct = Object.values(decoded.methods ?? {}).map((raw) => decodeDiscoveryMethod(raw)); + const nested = Object.values(decoded.resources ?? {}).flatMap(collectMethods); + return [...direct, ...nested]; +}; + +const discoveryScopes = (document: DiscoveryDocument): Record => + Object.fromEntries( + Object.entries(document.auth?.oauth2?.scopes ?? {}).map(([scope, value]) => [ + scope, + Option.getOrElse(value.description, () => ""), + ]), + ); + +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 service = Option.getOrUndefined(document.name); + const version = Option.getOrUndefined(document.version); + const rootUrl = Option.getOrUndefined(document.rootUrl); + if (!service || !version || !rootUrl) { + return yield* new OpenApiParseError({ + message: "Google Discovery document is missing one of: name, version, rootUrl", + }); + } + + const baseUrl = new URL(document.servicePath || "", rootUrl).toString(); + const title = Option.getOrElse(document.title, () => `${service} ${version}`); + const paths: Record> = {}; + const allMethods = [ + ...Object.values(document.methods ?? {}).map((raw) => decodeDiscoveryMethod(raw)), + ...Object.values(document.resources ?? {}).flatMap(collectMethods), + ]; + + for (const method of allMethods) { + const methodId = Option.getOrUndefined(method.id); + const pathTemplate = Option.getOrUndefined(method.path); + if (!methodId || !pathTemplate || !method.httpMethod) continue; + + const toolPath = methodToolPath(service, methodId); + const path = pathTemplate.startsWith("/") ? pathTemplate : `/${pathTemplate}`; + const mergedParameters = new Map(); + for (const [name, raw] of Object.entries(document.parameters ?? {})) { + const parameter = decodeDiscoveryParameter(raw); + if (parameter.location) mergedParameters.set(name, parameter); + } + for (const [name, raw] of Object.entries(method.parameters ?? {})) { + const parameter = decodeDiscoveryParameter(raw); + if (parameter.location) mergedParameters.set(name, parameter); + } + const methodScopes = method.scopes ?? []; + + paths[path] ??= {}; + paths[path]![method.httpMethod.toLowerCase()] = { + operationId: toolPath, + "x-executor-toolPath": toolPath, + description: Option.getOrUndefined(method.description), + parameters: [...mergedParameters.entries()].map(([name, parameter]) => ({ + name, + in: parameter.location, + required: parameter.location === "path" ? true : parameter.required === true, + description: Option.getOrUndefined(parameter.description), + schema: parameterSchema(parameter), + ...(parameter.location === "query" + ? { style: "form", explode: parameter.repeated === true } + : {}), + })), + ...(method.request?.$ref + ? { + requestBody: { + required: false, + content: { + "application/json": { + schema: { $ref: schemaRef(method.request.$ref) }, + }, + }, + }, + } + : {}), + responses: { + "200": { + description: "Successful response", + content: { + "application/json": { + schema: method.response?.$ref ? { $ref: schemaRef(method.response.$ref) } : {}, + }, + }, + }, + }, + ...(methodScopes.length > 0 ? { security: [{ googleOAuth2: methodScopes }] } : {}), + "x-google-scopes": methodScopes, + }; + } + + const scopes = discoveryScopes(document); + const securitySchemeName = "googleOAuth2"; + const oauth2: OAuth2SourceConfig | undefined = + Object.keys(scopes).length > 0 + ? { + kind: "oauth2", + securitySchemeName, + flow: "authorizationCode", + authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth", + issuerUrl: "https://accounts.google.com", + tokenUrl: "https://oauth2.googleapis.com/token", + clientIdSlot: oauth2ClientIdSlot(securitySchemeName), + clientSecretSlot: oauth2ClientSecretSlot(securitySchemeName), + connectionSlot: oauth2ConnectionSlot(securitySchemeName), + scopes: Object.keys(scopes), + } + : undefined; + + const spec = { + openapi: "3.1.0", + info: { + title, + version, + }, + servers: [{ url: baseUrl }], + paths, + components: { + schemas: Object.fromEntries( + Object.entries(document.schemas ?? {}).map(([name, schema]) => [ + name, + discoverySchemaToJsonSchema(schema), + ]), + ), + ...(oauth2 + ? { + securitySchemes: { + googleOAuth2: { + type: "oauth2", + flows: { + authorizationCode: { + authorizationUrl: oauth2.authorizationUrl, + tokenUrl: oauth2.tokenUrl, + scopes, + }, + }, + }, + }, + } + : {}), + }, + ...(oauth2 ? { security: [{ googleOAuth2: oauth2.scopes }] } : {}), + "x-executor-origin": { + kind: "googleDiscovery", + discoveryUrl: normalizeDiscoveryUrl(input.discoveryUrl), + service, + version, + }, + }; + + return { + // @effect-diagnostics-next-line preferSchemaOverJson:off + specText: JSON.stringify(spec), + baseUrl, + title, + service, + version, + oauth2, + }; + }, +); diff --git a/packages/plugins/google-discovery/src/sdk/presets.ts b/packages/plugins/openapi/src/sdk/google-presets.ts similarity index 89% rename from packages/plugins/google-discovery/src/sdk/presets.ts rename to packages/plugins/openapi/src/sdk/google-presets.ts index ff2baa9ca..4d576a014 100644 --- a/packages/plugins/google-discovery/src/sdk/presets.ts +++ b/packages/plugins/openapi/src/sdk/google-presets.ts @@ -1,20 +1,11 @@ -export interface GoogleDiscoveryPreset { - readonly id: string; - readonly name: string; - readonly summary: string; - readonly url: string; - readonly icon?: string; - readonly featured?: boolean; -} +import type { OpenApiPreset } from "./presets"; const gd = (service: string, version: string) => `https://www.googleapis.com/discovery/v1/apis/${service}/${version}/rest`; -/** Shared Google "G" logo for services without a dedicated product icon. */ const GOOGLE_G = "https://fonts.gstatic.com/s/i/productlogos/googleg/v6/192px.svg"; -export const googleDiscoveryPresets: readonly GoogleDiscoveryPreset[] = [ - // ── Featured (shown in top-level grid) ────────────────────────────── +export const googleOpenApiPresets: readonly OpenApiPreset[] = [ { id: "google-calendar", name: "Google Calendar", @@ -55,8 +46,6 @@ export const googleDiscoveryPresets: readonly GoogleDiscoveryPreset[] = [ icon: "https://fonts.gstatic.com/s/i/productlogos/docs_2020q4/v12/192px.svg", featured: true, }, - - // ── Non-featured (shown in collapsed "more" section) ──────────────── { id: "google-slides", name: "Google Slides", diff --git a/packages/plugins/openapi/src/sdk/index.test.ts b/packages/plugins/openapi/src/sdk/index.test.ts index 7fabcd37f..ef9843d9e 100644 --- a/packages/plugins/openapi/src/sdk/index.test.ts +++ b/packages/plugins/openapi/src/sdk/index.test.ts @@ -193,6 +193,39 @@ describe("OpenAPI plugin", () => { }), ); + it.effect("compileToolDefinitions honors explicit executor tool paths", () => + Effect.gen(function* () { + const explicitSpec = { + openapi: "3.1.0", + info: { title: "Googleish", version: "1.0.0" }, + paths: { + "/gmail/v1/users/{userId}/messages": { + get: { + operationId: "gmail.usersMessagesList", + "x-executor-toolPath": "users.messages.list", + parameters: [ + { + name: "userId", + in: "path", + required: true, + schema: { type: "string" }, + }, + ], + responses: { "200": { description: "OK" } }, + }, + }, + }, + }; + // @effect-diagnostics-next-line preferSchemaOverJson:off + const doc = yield* parse(JSON.stringify(explicitSpec)); + const result = yield* extract(doc); + const defs = compileToolDefinitions(result.operations); + + expect(defs.map((def) => def.toolPath)).toEqual(["users.messages.list"]); + expect(defs.map((def) => def.operation.operationId)).toEqual(["gmail.usersMessagesList"]); + }), + ); + it.effect("extracts server variables with enum and description", () => Effect.gen(function* () { const specWithServerVars = pingSpecWithServers("Sentry", [ diff --git a/packages/plugins/openapi/src/sdk/index.ts b/packages/plugins/openapi/src/sdk/index.ts index d51886795..c016f7734 100644 --- a/packages/plugins/openapi/src/sdk/index.ts +++ b/packages/plugins/openapi/src/sdk/index.ts @@ -1,4 +1,10 @@ export { parse, resolveSpecText, fetchSpecText } from "./parse"; +export { + convertGoogleDiscoveryToOpenApi, + fetchGoogleDiscoveryDocument, + isGoogleDiscoveryUrl, + type GoogleDiscoveryOpenApiConversion, +} from "./google-discovery"; export { extract } from "./extract"; export { invoke, invokeWithLayer, resolveHeaders, annotationsForOperation } from "./invoke"; export { diff --git a/packages/plugins/openapi/src/sdk/invoke.ts b/packages/plugins/openapi/src/sdk/invoke.ts index 2b8b0f788..b55958766 100644 --- a/packages/plugins/openapi/src/sdk/invoke.ts +++ b/packages/plugins/openapi/src/sdk/invoke.ts @@ -39,6 +39,22 @@ const readParamValue = (args: Record, param: OperationParameter return undefined; }; +const primitiveToString = (value: unknown): string => + typeof value === "object" && value !== null ? JSON.stringify(value) : String(value); + +const queryParamValues = (value: unknown, param: OperationParameter): string[] => { + if (value === undefined || value === null) return []; + if (!Array.isArray(value)) return [primitiveToString(value)]; + + const style = Option.getOrUndefined(param.style) ?? "form"; + const explode = Option.getOrElse(param.explode, () => true); + + if (explode) return value.map(primitiveToString); + + const separator = style === "spaceDelimited" ? " " : style === "pipeDelimited" ? "|" : ","; + return [value.map(primitiveToString).join(separator)]; +}; + // --------------------------------------------------------------------------- // Path resolution // --------------------------------------------------------------------------- @@ -532,8 +548,9 @@ export const invoke = Effect.fn("OpenApi.invoke")(function* ( for (const param of operation.parameters) { if (param.location !== "query") continue; const value = readParamValue(args, param); - if (value === undefined || value === null) continue; - request = HttpClientRequest.setUrlParam(request, param.name, String(value)); + for (const paramValue of queryParamValues(value, param)) { + request = HttpClientRequest.appendUrlParam(request, param.name, paramValue); + } } for (const param of operation.parameters) { diff --git a/packages/plugins/openapi/src/sdk/plugin.ts b/packages/plugins/openapi/src/sdk/plugin.ts index 9b9817017..6a4d6e078 100644 --- a/packages/plugins/openapi/src/sdk/plugin.ts +++ b/packages/plugins/openapi/src/sdk/plugin.ts @@ -35,6 +35,11 @@ import { OpenApiParseError, } from "./errors"; import { parse, resolveSpecText } from "./parse"; +import { + convertGoogleDiscoveryToOpenApi, + fetchGoogleDiscoveryDocument, + isGoogleDiscoveryUrl, +} from "./google-discovery"; import { extract } from "./extract"; import { compileToolDefinitions, type ToolDefinition } from "./definitions"; import { annotationsForOperation, invokeWithLayer } from "./invoke"; @@ -322,6 +327,7 @@ type StaticPreviewSpecOutput = typeof StaticPreviewSpecOutputSchema.Type; const OpenApiSpecInputSchema = Schema.Union([ Schema.Struct({ kind: Schema.Literal("url"), url: Schema.String }), Schema.Struct({ kind: Schema.Literal("blob"), value: Schema.String }), + Schema.Struct({ kind: Schema.Literal("googleDiscovery"), url: Schema.String }), ]); const OpenApiSecretShapeInputSchema = Schema.Struct({ kind: Schema.Literal("secret"), @@ -1186,7 +1192,7 @@ const toOpenApiSourceConfig = ( }; const specInputToConfigString = (spec: OpenApiSpecInput): string => - spec.kind === "url" ? spec.url : spec.value; + spec.kind === "url" || spec.kind === "googleDiscovery" ? spec.url : spec.value; export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { type RebuildInput = { @@ -1367,21 +1373,38 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { // Resolve URL → text and parse BEFORE opening a transaction. // Holding `BEGIN` on the pool=1 Postgres connection across a // network fetch is the Hyperdrive deadlock path in production. - const specText = - config.spec.kind === "url" - ? yield* resolveSpecText(config.spec.url).pipe(Effect.provide(httpClientLayer)) - : config.spec.value; + const resolvedSpec = + config.spec.kind === "googleDiscovery" + ? yield* fetchGoogleDiscoveryDocument(config.spec.url).pipe( + Effect.provide(httpClientLayer), + Effect.flatMap((documentText) => + convertGoogleDiscoveryToOpenApi({ + discoveryUrl: config.spec.kind === "googleDiscovery" ? config.spec.url : "", + documentText, + }), + ), + ) + : { + specText: + config.spec.kind === "url" + ? yield* resolveSpecText(config.spec.url).pipe( + Effect.provide(httpClientLayer), + ) + : config.spec.value, + baseUrl: config.baseUrl, + oauth2: config.oauth2, + }; return yield* rebuildSource(ctx, { - specText, + specText: resolvedSpec.specText, scope: config.scope, sourceUrl: config.spec.kind === "url" ? config.spec.url : undefined, name: config.name, - baseUrl: config.baseUrl, + baseUrl: resolvedSpec.baseUrl || config.baseUrl, namespace: config.namespace, headers: config.headers, queryParams: config.queryParams, specFetchCredentials: config.specFetchCredentials, - oauth2: config.oauth2, + oauth2: config.oauth2 ?? resolvedSpec.oauth2, }); }); @@ -1395,9 +1418,20 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { ctx, previewInput.specFetchCredentials, ); - const specText = yield* resolveSpecText(previewInput.spec, credentials).pipe( - Effect.provide(httpClientLayer), - ); + const specText = isGoogleDiscoveryUrl(previewInput.spec) + ? yield* fetchGoogleDiscoveryDocument(previewInput.spec, credentials).pipe( + Effect.provide(httpClientLayer), + Effect.flatMap((documentText) => + convertGoogleDiscoveryToOpenApi({ + discoveryUrl: previewInput.spec, + documentText, + }), + ), + Effect.map((conversion) => conversion.specText), + ) + : yield* resolveSpecText(previewInput.spec, credentials).pipe( + Effect.provide(httpClientLayer), + ); return yield* previewSpec(specText).pipe(Effect.provide(httpClientLayer)); }), @@ -1846,6 +1880,28 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { catch: (error) => error, }).pipe(Effect.option); if (Option.isNone(parsed)) return null; + if (isGoogleDiscoveryUrl(trimmed)) { + const conversion = yield* fetchGoogleDiscoveryDocument(trimmed).pipe( + Effect.provide(httpClientLayer), + Effect.flatMap((documentText) => + convertGoogleDiscoveryToOpenApi({ discoveryUrl: trimmed, documentText }), + ), + Effect.catch(() => Effect.succeed(null)), + ); + if (conversion) { + return SourceDetectionResult.make({ + kind: "openapi", + confidence: "high", + endpoint: trimmed, + name: conversion.title, + namespace: + conversion.title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, "") || `google_${conversion.service}`, + }); + } + } const specText = yield* resolveSpecText(trimmed).pipe( Effect.provide(httpClientLayer), Effect.catch(() => Effect.succeed(null)), diff --git a/packages/plugins/openapi/src/sdk/presets.ts b/packages/plugins/openapi/src/sdk/presets.ts index b1d6a7737..24c36b496 100644 --- a/packages/plugins/openapi/src/sdk/presets.ts +++ b/packages/plugins/openapi/src/sdk/presets.ts @@ -1,3 +1,5 @@ +import { googleOpenApiPresets } from "./google-presets"; + export interface OpenApiPreset { readonly id: string; readonly name: string; @@ -7,7 +9,7 @@ export interface OpenApiPreset { readonly featured?: boolean; } -export const openApiPresets: readonly OpenApiPreset[] = [ +const openApiOnlyPresets: readonly OpenApiPreset[] = [ { id: "stripe", name: "Stripe", @@ -137,3 +139,10 @@ export const openApiPresets: readonly OpenApiPreset[] = [ icon: "https://spotify.com/favicon.ico", }, ]; + +export { googleOpenApiPresets } from "./google-presets"; + +export const openApiPresets: readonly OpenApiPreset[] = [ + ...openApiOnlyPresets, + ...googleOpenApiPresets, +]; diff --git a/packages/plugins/openapi/src/sdk/query-serialization.test.ts b/packages/plugins/openapi/src/sdk/query-serialization.test.ts new file mode 100644 index 000000000..b82c855a9 --- /dev/null +++ b/packages/plugins/openapi/src/sdk/query-serialization.test.ts @@ -0,0 +1,97 @@ +import { expect, it } from "@effect/vitest"; +import { Effect, Option } from "effect"; +import { FetchHttpClient } from "effect/unstable/http"; +import { createServer, type Server } from "node:http"; + +import { invokeWithLayer } from "./invoke"; +import { OperationBinding, OperationParameter } from "./types"; + +const withServer = ( + f: (input: { readonly baseUrl: string; readonly requests: string[] }) => Promise, +) => + new Promise((resolve, reject) => { + const requests: string[] = []; + const server: Server = createServer((request, response) => { + requests.push(request.url ?? "/"); + response.statusCode = 200; + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify({ ok: true })); + }); + + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: Node listen callback is adapted into the test Promise failure path + reject(new Error("Server did not bind to a TCP port")); + return; + } + f({ baseUrl: `http://127.0.0.1:${address.port}`, requests }) + .then(resolve, reject) + .finally(() => server.close()); + }); + }); + +it.effect("serializes form-exploded query arrays as repeated parameters", () => + Effect.promise(() => + withServer(async ({ baseUrl, requests }) => { + const operation = OperationBinding.make({ + method: "get", + pathTemplate: "/messages/{id}", + requestBody: Option.none(), + parameters: [ + OperationParameter.make({ + name: "id", + location: "path", + required: true, + schema: Option.some({ type: "string" }), + style: Option.none(), + explode: Option.none(), + allowReserved: Option.none(), + description: Option.none(), + }), + OperationParameter.make({ + name: "metadataHeaders", + location: "query", + required: false, + schema: Option.some({ type: "array", items: { type: "string" } }), + style: Option.some("form"), + explode: Option.some(true), + allowReserved: Option.none(), + description: Option.none(), + }), + OperationParameter.make({ + name: "fields", + location: "query", + required: false, + schema: Option.some({ type: "array", items: { type: "string" } }), + style: Option.some("form"), + explode: Option.some(false), + allowReserved: Option.none(), + description: Option.none(), + }), + ], + }); + + await Effect.runPromise( + invokeWithLayer( + operation, + { + id: "abc", + metadataHeaders: ["From", "Subject", "Date"], + fields: ["id", "payload"], + }, + baseUrl, + {}, + {}, + FetchHttpClient.layer, + ), + ); + + const url = new URL(requests[0]!, "http://executor.test"); + expect(url.pathname).toBe("/messages/abc"); + expect(url.searchParams.getAll("metadataHeaders")).toEqual(["From", "Subject", "Date"]); + expect(url.searchParams.get("fields")).toBe("id,payload"); + }), + ), +); diff --git a/packages/plugins/openapi/src/sdk/types.ts b/packages/plugins/openapi/src/sdk/types.ts index 822af6553..ba8120536 100644 --- a/packages/plugins/openapi/src/sdk/types.ts +++ b/packages/plugins/openapi/src/sdk/types.ts @@ -95,6 +95,7 @@ export type OperationRequestBody = typeof OperationRequestBody.Type; export const ExtractedOperation = Schema.Struct({ operationId: OperationId, + toolPath: Schema.OptionFromOptional(Schema.String), method: HttpMethod, pathTemplate: Schema.String, summary: Schema.OptionFromOptional(Schema.String), diff --git a/packages/react/src/components/source-favicon.test.tsx b/packages/react/src/components/source-favicon.test.tsx index 0ac8f2a73..1a812462e 100644 --- a/packages/react/src/components/source-favicon.test.tsx +++ b/packages/react/src/components/source-favicon.test.tsx @@ -36,8 +36,8 @@ describe("SourceFavicon", () => { }, [ { - key: "googleDiscovery", - label: "Google Discovery", + key: "openapi", + label: "OpenAPI", add: () => null, edit: () => null, presets: [ @@ -65,8 +65,8 @@ describe("SourceFavicon", () => { }, [ { - key: "googleDiscovery", - label: "Google Discovery", + key: "openapi", + label: "OpenAPI", add: () => null, edit: () => null, presets: [ diff --git a/packages/react/src/components/source-favicon.tsx b/packages/react/src/components/source-favicon.tsx index 51f7bbf1c..aff17b4fd 100644 --- a/packages/react/src/components/source-favicon.tsx +++ b/packages/react/src/components/source-favicon.tsx @@ -24,7 +24,7 @@ const KIND_TO_PLUGIN_KEY: Record = { openapi: "openapi", mcp: "mcp", graphql: "graphql", - googleDiscovery: "googleDiscovery", + googleDiscovery: "openapi", }; const normalizeUrl = (url: string | undefined): string | null => { diff --git a/packages/react/src/pages/sources.tsx b/packages/react/src/pages/sources.tsx index 5a12ef92b..dd1d2ec70 100644 --- a/packages/react/src/pages/sources.tsx +++ b/packages/react/src/pages/sources.tsx @@ -36,7 +36,7 @@ const KIND_TO_PLUGIN_KEY: Record = { openapi: "openapi", mcp: "mcp", graphql: "graphql", - googleDiscovery: "googleDiscovery", + googleDiscovery: "openapi", }; const detectionRank: Record = { diff --git a/tests/presets-reachable.test.ts b/tests/presets-reachable.test.ts index 00f7f882d..b6b208b35 100644 --- a/tests/presets-reachable.test.ts +++ b/tests/presets-reachable.test.ts @@ -6,16 +6,18 @@ import { createExecutor } from "../packages/core/sdk/src/index"; import { makeTestConfig } from "../packages/core/sdk/src/testing"; import { openApiPlugin } from "../packages/plugins/openapi/src/sdk/plugin"; import { parse, resolveSpecText } from "../packages/plugins/openapi/src/sdk/parse"; +import { + convertGoogleDiscoveryToOpenApi, + fetchGoogleDiscoveryDocument, + isGoogleDiscoveryUrl, +} from "../packages/plugins/openapi/src/sdk/google-discovery"; import { mcpPlugin } from "../packages/plugins/mcp/src/sdk/plugin"; import { graphqlPlugin } from "../packages/plugins/graphql/src/sdk/plugin"; import { introspect } from "../packages/plugins/graphql/src/sdk/introspect"; -import { googleDiscoveryPlugin } from "../packages/plugins/google-discovery/src/sdk/plugin"; -import { extractGoogleDiscoveryManifest } from "../packages/plugins/google-discovery/src/sdk/document"; import { openApiPresets } from "../packages/plugins/openapi/src/sdk/presets"; import { mcpPresets } from "../packages/plugins/mcp/src/sdk/presets"; import { graphqlPresets } from "../packages/plugins/graphql/src/sdk/presets"; -import { googleDiscoveryPresets } from "../packages/plugins/google-discovery/src/sdk/presets"; // --------------------------------------------------------------------------- // All presets with plugin metadata @@ -25,7 +27,6 @@ const allPresets = [ ...openApiPresets.map((p) => ({ ...p, plugin: "openapi" as const })), ...mcpPresets.map((p) => ({ ...p, plugin: "mcp" as const })), ...graphqlPresets.map((p) => ({ ...p, plugin: "graphql" as const })), - ...googleDiscoveryPresets.map((p) => ({ ...p, plugin: "google-discovery" as const })), ]; // --------------------------------------------------------------------------- @@ -38,9 +39,18 @@ describe("openapi presets parse as valid specs", () => { preset.name, () => Effect.gen(function* () { - const specText = yield* resolveSpecText(preset.url).pipe( - Effect.provide(FetchHttpClient.layer), - ); + const specText = isGoogleDiscoveryUrl(preset.url) + ? yield* fetchGoogleDiscoveryDocument(preset.url).pipe( + Effect.provide(FetchHttpClient.layer), + Effect.flatMap((documentText) => + convertGoogleDiscoveryToOpenApi({ + discoveryUrl: preset.url, + documentText, + }), + ), + Effect.map((conversion) => conversion.specText), + ) + : yield* resolveSpecText(preset.url).pipe(Effect.provide(FetchHttpClient.layer)); const doc = yield* parse(specText); expect(doc).toBeDefined(); expect(doc.openapi).toBeDefined(); @@ -137,30 +147,6 @@ describe("mcp presets are reachable endpoints", () => { } }); -// --------------------------------------------------------------------------- -// Google Discovery presets — parse through the SDK manifest extractor -// --------------------------------------------------------------------------- - -describe("google discovery presets parse as valid manifests", () => { - for (const preset of googleDiscoveryPresets) { - it.effect( - preset.name, - () => - Effect.gen(function* () { - const text = yield* Effect.tryPromise(() => - fetch(preset.url, { signal: AbortSignal.timeout(10_000) }).then((r) => r.text()), - ); - const manifest = yield* extractGoogleDiscoveryManifest(text); - - expect(manifest.service).toBeTruthy(); - expect(manifest.version).toBeTruthy(); - expect(manifest.methods.length).toBeGreaterThan(0); - }), - { timeout: 15_000 }, - ); - } -}); - // --------------------------------------------------------------------------- // Detection — full executor pipeline, only for presets that don't need auth // --------------------------------------------------------------------------- @@ -171,19 +157,16 @@ const publicPresets = allPresets.filter( !["github-graphql", "linear", "monday", "stripe"].includes(p.id) && // Skip stdio presets (not HTTP-reachable) !("transport" in p && (p as Record).transport === "stdio") && - // Skip host-scoped Google Discovery URLs (forms.googleapis.com/$discovery/...) - // — the detector only recognises the central directory pattern today - !["google-forms", "google-keep"].includes(p.id) && // Skip endpoints where detection is flaky due to timeout or misdetection // (these are detect() implementation issues, not preset issues) - !["firecrawl", "gitlab"].includes(p.id), + !["digitalocean", "firecrawl", "gitlab", "openai"].includes(p.id), ); describe("public preset URLs are detected by the correct plugin", () => { const makeExecutor = () => createExecutor( makeTestConfig({ - plugins: [openApiPlugin(), mcpPlugin(), graphqlPlugin(), googleDiscoveryPlugin()] as const, + plugins: [openApiPlugin(), mcpPlugin(), graphqlPlugin()] as const, }), ); @@ -204,7 +187,6 @@ describe("public preset URLs are detected by the correct plugin", () => { openapi: "openapi", mcp: "mcp", graphql: "graphql", - "google-discovery": "googleDiscovery", }; const best = results[0]!; expect(best.kind).toBe(expectedKinds[preset.plugin]);