Unified · Split
| @@ -0,0 +1 @@ | |||
| 1 | use effect vitest and run tests via vitest not bun test | ||
| @@ -0,0 +1 @@ | |||
| 1 | use effect vitest and run tests via vitest not bun test | ||
| @@ -16,6 +16,7 @@ · 15 unmodified lines | |||
| 16 | "@executor/plugin-keychain": "workspace:*", | 16 | "@executor/plugin-keychain": "workspace:*", |
| 17 | "@executor/plugin-onepassword": "workspace:*", | 17 | "@executor/plugin-onepassword": "workspace:*", |
| 18 | "@executor/plugin-mcp": "workspace:*", | 18 | "@executor/plugin-mcp": "workspace:*", |
| 19 | "@executor/plugin-graphql": "workspace:*", | ||
| 19 | "@executor/plugin-openapi": "workspace:*", | 20 | "@executor/plugin-openapi": "workspace:*", |
| 20 | "@executor/sdk": "workspace:*", | 21 | "@executor/sdk": "workspace:*", |
| 21 | "@executor/host-mcp": "workspace:*", | 22 | "@executor/host-mcp": "workspace:*", |
| @@ -0,0 +1,17 @@ | |||
| 1 | import { Effect, Layer } from "effect"; | ||
| 2 | import { | ||
| 3 | GraphqlHandlers, | ||
| 4 | GraphqlExtensionService, | ||
| 5 | } from "@executor/plugin-graphql/api"; | ||
| 6 | import { ExecutorService } from "../services/executor"; | ||
| 7 | |||
| 8 | // Wire GraphqlExtensionService from the executor's graphql extension | ||
| 9 | const GraphqlExtensionLive = Layer.effect( | ||
| 10 | GraphqlExtensionService, | ||
| 11 | Effect.map(ExecutorService, (executor) => executor.graphql), | ||
| 12 | ); | ||
| 13 | |||
| 14 | export const GraphqlHandlersLive = Layer.provide( | ||
| 15 | GraphqlHandlers, | ||
| 16 | GraphqlExtensionLive, | ||
| 17 | ); | ||
| @@ -11,6 +11,7 @@ import { OpenApiGroup } from "@executor/plugin-openapi/api"; · 10 unmodified lines | |||
| 11 | import { McpGroup } from "@executor/plugin-mcp/api"; | 11 | import { McpGroup } from "@executor/plugin-mcp/api"; |
| 12 | import { GoogleDiscoveryGroup } from "@executor/plugin-google-discovery/api"; | 12 | import { GoogleDiscoveryGroup } from "@executor/plugin-google-discovery/api"; |
| 13 | import { OnePasswordGroup } from "@executor/plugin-onepassword/api"; | 13 | import { OnePasswordGroup } from "@executor/plugin-onepassword/api"; |
| 14 | import { GraphqlGroup } from "@executor/plugin-graphql/api"; | ||
| 14 | import { ToolsHandlers } from "./handlers/tools"; | 15 | import { ToolsHandlers } from "./handlers/tools"; |
| 15 | import { SourcesHandlers } from "./handlers/sources"; | 16 | import { SourcesHandlers } from "./handlers/sources"; |
| 16 | import { SecretsHandlers } from "./handlers/secrets"; | 17 | import { SecretsHandlers } from "./handlers/secrets"; |
| @@ -18,6 +19,7 @@ import { OpenApiHandlersLive } from "./handlers/openapi"; · 1 unmodified line | |||
| 18 | import { McpSourceHandlersLive } from "./handlers/mcp-source"; | 19 | import { McpSourceHandlersLive } from "./handlers/mcp-source"; |
| 19 | import { GoogleDiscoveryHandlersLive } from "./handlers/google-discovery"; | 20 | import { GoogleDiscoveryHandlersLive } from "./handlers/google-discovery"; |
| 20 | import { OnePasswordHandlersLive } from "./handlers/onepassword"; | 21 | import { OnePasswordHandlersLive } from "./handlers/onepassword"; |
| 22 | import { GraphqlHandlersLive } from "./handlers/graphql"; | ||
| 21 | import { ExecutorService, ExecutorServiceLayer, getExecutor, type ServerExecutor } from "./services/executor"; | 23 | import { ExecutorService, ExecutorServiceLayer, getExecutor, type ServerExecutor } from "./services/executor"; |
| 22 | import { createMcpRequestHandler, type McpRequestHandler } from "./mcp"; | 24 | import { createMcpRequestHandler, type McpRequestHandler } from "./mcp"; |
| 23 | 25 | ||
| @@ -28,7 +30,8 @@ import { createMcpRequestHandler, type McpRequestHandler } from "./mcp"; · 4 unmodified lines | |||
| 28 | const ExecutorApiWithPlugins = addGroup(OpenApiGroup) | 30 | const ExecutorApiWithPlugins = addGroup(OpenApiGroup) |
| 29 | .add(McpGroup) | 31 | .add(McpGroup) |
| 30 | .add(GoogleDiscoveryGroup) | 32 | .add(GoogleDiscoveryGroup) |
| 31 | .add(OnePasswordGroup); | 33 | .add(OnePasswordGroup) |
| 34 | .add(GraphqlGroup); | ||
| 32 | 35 | ||
| 33 | // --------------------------------------------------------------------------- | 36 | // --------------------------------------------------------------------------- |
| 34 | // API Layer | 37 | // API Layer |
| @@ -43,6 +46,7 @@ const ApiBase = HttpApiBuilder.api(ExecutorApiWithPlugins).pipe( · 8 unmodified lines | |||
| 43 | McpSourceHandlersLive, | 46 | McpSourceHandlersLive, |
| 44 | GoogleDiscoveryHandlersLive, | 47 | GoogleDiscoveryHandlersLive, |
| 45 | OnePasswordHandlersLive, | 48 | OnePasswordHandlersLive, |
| 49 | GraphqlHandlersLive, | ||
| 46 | ]), | 50 | ]), |
| 47 | ); | 51 | ); |
| 48 | 52 | ||
| @@ -12,6 +12,11 @@ import { · 11 unmodified lines | |||
| 12 | makeKvBindingStore as makeKvGoogleDiscoveryBindingStore, | 12 | makeKvBindingStore as makeKvGoogleDiscoveryBindingStore, |
| 13 | type GoogleDiscoveryPluginExtension, | 13 | type GoogleDiscoveryPluginExtension, |
| 14 | } from "@executor/plugin-google-discovery"; | 14 | } from "@executor/plugin-google-discovery"; |
| 15 | import { | ||
| 16 | graphqlPlugin, | ||
| 17 | makeKvOperationStore as makeKvGraphqlOperationStore, | ||
| 18 | type GraphqlPluginExtension, | ||
| 19 | } from "@executor/plugin-graphql"; | ||
| 15 | import { keychainPlugin } from "@executor/plugin-keychain"; | 20 | import { keychainPlugin } from "@executor/plugin-keychain"; |
| 16 | import { fileSecretsPlugin } from "@executor/plugin-file-secrets"; | 21 | import { fileSecretsPlugin } from "@executor/plugin-file-secrets"; |
| 17 | import { onepasswordPlugin, type OnePasswordExtension } from "@executor/plugin-onepassword"; | 22 | import { onepasswordPlugin, type OnePasswordExtension } from "@executor/plugin-onepassword"; |
| @@ -22,6 +27,7 @@ type ServerPlugins = readonly [ · 4 unmodified lines | |||
| 22 | ExecutorPlugin<"openapi", OpenApiPluginExtension>, | 27 | ExecutorPlugin<"openapi", OpenApiPluginExtension>, |
| 23 | ExecutorPlugin<"mcp", McpPluginExtension>, | 28 | ExecutorPlugin<"mcp", McpPluginExtension>, |
| 24 | ExecutorPlugin<"googleDiscovery", GoogleDiscoveryPluginExtension>, | 29 | ExecutorPlugin<"googleDiscovery", GoogleDiscoveryPluginExtension>, |
| 30 | ExecutorPlugin<"graphql", GraphqlPluginExtension>, | ||
| 25 | ReturnType<typeof keychainPlugin>, | 31 | ReturnType<typeof keychainPlugin>, |
| 26 | ReturnType<typeof fileSecretsPlugin>, | 32 | ReturnType<typeof fileSecretsPlugin>, |
| 27 | ExecutorPlugin<"onepassword", OnePasswordExtension>, | 33 | ExecutorPlugin<"onepassword", OnePasswordExtension>, |
| @@ -78,6 +84,9 @@ const ExecutorLayer = Layer.effect( · 50 unmodified lines | |||
| 78 | googleDiscoveryPlugin({ | 84 | googleDiscoveryPlugin({ |
| 79 | bindingStore: makeKvGoogleDiscoveryBindingStore(kv, "google-discovery"), | 85 | bindingStore: makeKvGoogleDiscoveryBindingStore(kv, "google-discovery"), |
| 80 | }), | 86 | }), |
| 87 | graphqlPlugin({ | ||
| 88 | operationStore: makeKvGraphqlOperationStore(kv, "graphql"), | ||
| 89 | }), | ||
| 81 | keychainPlugin(), | 90 | keychainPlugin(), |
| 82 | fileSecretsPlugin(), | 91 | fileSecretsPlugin(), |
| 83 | onepasswordPlugin({ | 92 | onepasswordPlugin({ |
| @@ -7,6 +7,7 @@ · 6 unmodified lines | |||
| 7 | "@executor/plugin-onepassword": "workspace:*", | 7 | "@executor/plugin-onepassword": "workspace:*", |
| 8 | "@executor/plugin-mcp": "workspace:*", | 8 | "@executor/plugin-mcp": "workspace:*", |
| 9 | "@executor/plugin-google-discovery": "workspace:*", | 9 | "@executor/plugin-google-discovery": "workspace:*", |
| 10 | "@executor/plugin-graphql": "workspace:*", | ||
| 10 | "@executor/plugin-openapi": "workspace:*", | 11 | "@executor/plugin-openapi": "workspace:*", |
| 11 | "@executor/sdk": "workspace:*", | 12 | "@executor/sdk": "workspace:*", |
| 12 | "@executor/react": "workspace:*", | 13 | "@executor/react": "workspace:*", |
| @@ -5,6 +5,7 @@ import type { SourcePlugin } from "@executor/react"; · 4 unmodified lines | |||
| 5 | import { openApiSourcePlugin } from "@executor/plugin-openapi/react"; | 5 | import { openApiSourcePlugin } from "@executor/plugin-openapi/react"; |
| 6 | import { mcpSourcePlugin } from "@executor/plugin-mcp/react"; | 6 | import { mcpSourcePlugin } from "@executor/plugin-mcp/react"; |
| 7 | import { googleDiscoverySourcePlugin } from "@executor/plugin-google-discovery/react"; | 7 | import { googleDiscoverySourcePlugin } from "@executor/plugin-google-discovery/react"; |
| 8 | import { graphqlSourcePlugin } from "@executor/plugin-graphql/react"; | ||
| 8 | import { McpInstallCard } from "../components/mcp-install-card"; | 9 | import { McpInstallCard } from "../components/mcp-install-card"; |
| 9 | 10 | ||
| 10 | // --------------------------------------------------------------------------- | 11 | // --------------------------------------------------------------------------- |
| @@ -15,6 +16,7 @@ const sourcePlugins: SourcePlugin[] = [ · 4 unmodified lines | |||
| 15 | openApiSourcePlugin, | 16 | openApiSourcePlugin, |
| 16 | mcpSourcePlugin, | 17 | mcpSourcePlugin, |
| 17 | googleDiscoverySourcePlugin, | 18 | googleDiscoverySourcePlugin, |
| 19 | graphqlSourcePlugin, | ||
| 18 | ]; | 20 | ]; |
| 19 | 21 | ||
| 20 | // --------------------------------------------------------------------------- | 22 | // --------------------------------------------------------------------------- |
| @@ -22,6 +22,7 @@ · 21 unmodified lines | |||
| 22 | "@executor/host-mcp": "workspace:*", | 22 | "@executor/host-mcp": "workspace:*", |
| 23 | "@executor/plugin-file-secrets": "workspace:*", | 23 | "@executor/plugin-file-secrets": "workspace:*", |
| 24 | "@executor/plugin-google-discovery": "workspace:*", | 24 | "@executor/plugin-google-discovery": "workspace:*", |
| 25 | "@executor/plugin-graphql": "workspace:*", | ||
| 25 | "@executor/plugin-keychain": "workspace:*", | 26 | "@executor/plugin-keychain": "workspace:*", |
| 26 | "@executor/plugin-mcp": "workspace:*", | 27 | "@executor/plugin-mcp": "workspace:*", |
| 27 | "@executor/plugin-onepassword": "workspace:*", | 28 | "@executor/plugin-onepassword": "workspace:*", |
| @@ -42,6 +43,7 @@ · 14 unmodified lines | |||
| 42 | "version": "0.0.0", | 43 | "version": "0.0.0", |
| 43 | "dependencies": { | 44 | "dependencies": { |
| 44 | "@executor/plugin-google-discovery": "workspace:*", | 45 | "@executor/plugin-google-discovery": "workspace:*", |
| 46 | "@executor/plugin-graphql": "workspace:*", | ||
| 45 | "@executor/plugin-mcp": "workspace:*", | 47 | "@executor/plugin-mcp": "workspace:*", |
| 46 | "@executor/plugin-onepassword": "workspace:*", | 48 | "@executor/plugin-onepassword": "workspace:*", |
| 47 | "@executor/plugin-openapi": "workspace:*", | 49 | "@executor/plugin-openapi": "workspace:*", |
| @@ -251,6 +253,44 @@ · 203 unmodified lines | |||
| 251 | "react", | 253 | "react", |
| 252 | ], | 254 | ], |
| 253 | }, | 255 | }, |
| 256 | "packages/plugins/graphql": { | ||
| 257 | "name": "@executor/plugin-graphql", | ||
| 258 | "version": "0.0.0", | ||
| 259 | "dependencies": { | ||
| 260 | "@effect/platform": "catalog:", | ||
| 261 | "@effect/platform-node": "catalog:", | ||
| 262 | "@executor/sdk": "workspace:*", | ||
| 263 | "effect": "catalog:", | ||
| 264 | }, | ||
| 265 | "devDependencies": { | ||
| 266 | "@effect-atom/atom-react": "^0.5.0", | ||
| 267 | "@effect/vitest": "catalog:", | ||
| 268 | "@executor/api": "workspace:*", | ||
| 269 | "@executor/react": "workspace:*", | ||
| 270 | "@executor/ui": "workspace:*", | ||
| 271 | "@types/node": "catalog:", | ||
| 272 | "@types/react": "^19.1.0", | ||
| 273 | "bun-types": "catalog:", | ||
| 274 | "react": "^19.1.0", | ||
| 275 | "vitest": "catalog:", | ||
| 276 | }, | ||
| 277 | "peerDependencies": { | ||
| 278 | "@effect-atom/atom-react": "^0.5.0", | ||
| 279 | "@executor/api": "workspace:*", | ||
| 280 | "@executor/react": "workspace:*", | ||
| 281 | "@executor/ui": "workspace:*", | ||
| 282 | "@tanstack/react-router": "^1.168.10", | ||
| 283 | "react": "^19.1.0", | ||
| 284 | }, | ||
| 285 | "optionalPeers": [ | ||
| 286 | "@effect-atom/atom-react", | ||
| 287 | "@executor/api", | ||
| 288 | "@executor/react", | ||
| 289 | "@executor/ui", | ||
| 290 | "@tanstack/react-router", | ||
| 291 | "react", | ||
| 292 | ], | ||
| 293 | }, | ||
| 254 | "packages/plugins/keychain": { | 294 | "packages/plugins/keychain": { |
| 255 | "name": "@executor/plugin-keychain", | 295 | "name": "@executor/plugin-keychain", |
| 256 | "version": "0.0.0", | 296 | "version": "0.0.0", |
| @@ -595,6 +635,8 @@ · 338 unmodified lines | |||
| 595 | 635 | ||
| 596 | "@executor/plugin-google-discovery": ["@executor/plugin-google-discovery@workspace:packages/plugins/google-discovery"], | 636 | "@executor/plugin-google-discovery": ["@executor/plugin-google-discovery@workspace:packages/plugins/google-discovery"], |
| 597 | 637 | ||
| 638 | "@executor/plugin-graphql": ["@executor/plugin-graphql@workspace:packages/plugins/graphql"], | ||
| 639 | |||
| 598 | "@executor/plugin-keychain": ["@executor/plugin-keychain@workspace:packages/plugins/keychain"], | 640 | "@executor/plugin-keychain": ["@executor/plugin-keychain@workspace:packages/plugins/keychain"], |
| 599 | 641 | ||
| 600 | "@executor/plugin-mcp": ["@executor/plugin-mcp@workspace:packages/plugins/mcp"], | 642 | "@executor/plugin-mcp": ["@executor/plugin-mcp@workspace:packages/plugins/mcp"], |
| @@ -9,7 +9,7 @@ import type { · 8 unmodified lines | |||
| 9 | InvokeOptions, | 9 | InvokeOptions, |
| 10 | RuntimeToolHandler, | 10 | RuntimeToolHandler, |
| 11 | } from "../tools"; | 11 | } from "../tools"; |
| 12 | import { reattachDefs } from "../schema-refs"; | 12 | import { reattachDefs, normalizeRefs } from "../schema-refs"; |
| 13 | 13 | ||
| 14 | export const makeInMemoryToolRegistry = () => { | 14 | export const makeInMemoryToolRegistry = () => { |
| 15 | const tools = new Map<string, ToolRegistration>(); | 15 | const tools = new Map<string, ToolRegistration>(); |
| @@ -83,14 +83,14 @@ export const makeInMemoryToolRegistry = () => { · 67 unmodified lines | |||
| 83 | registerDefinitions: (defs: Record<string, unknown>) => | 83 | registerDefinitions: (defs: Record<string, unknown>) => |
| 84 | Effect.sync(() => { | 84 | Effect.sync(() => { |
| 85 | for (const [k, v] of Object.entries(defs)) { | 85 | for (const [k, v] of Object.entries(defs)) { |
| 86 | sharedDefs.set(k, v); | 86 | sharedDefs.set(k, normalizeRefs(v)); |
| 87 | } | 87 | } |
| 88 | }), | 88 | }), |
| 89 | 89 | ||
| 90 | registerRuntimeDefinitions: (defs: Record<string, unknown>) => | 90 | registerRuntimeDefinitions: (defs: Record<string, unknown>) => |
| 91 | Effect.sync(() => { | 91 | Effect.sync(() => { |
| 92 | for (const [k, v] of Object.entries(defs)) { | 92 | for (const [k, v] of Object.entries(defs)) { |
| 93 | runtimeDefs.set(k, v); | 93 | runtimeDefs.set(k, normalizeRefs(v)); |
| 94 | } | 94 | } |
| 95 | }), | 95 | }), |
| 96 | 96 | ||
| @@ -142,14 +142,22 @@ export const makeInMemoryToolRegistry = () => { · 45 unmodified lines | |||
| 142 | register: (newTools: readonly ToolRegistration[]) => | 142 | register: (newTools: readonly ToolRegistration[]) => |
| 143 | Effect.sync(() => { | 143 | Effect.sync(() => { |
| 144 | for (const t of newTools) { | 144 | for (const t of newTools) { |
| 145 | tools.set(t.id, t); | 145 | tools.set(t.id, { |
| 146 | ...t, | ||
| 147 | inputSchema: normalizeRefs(t.inputSchema), | ||
| 148 | outputSchema: normalizeRefs(t.outputSchema), | ||
| 149 | }); | ||
| 146 | } | 150 | } |
| 147 | }), | 151 | }), |
| 148 | 152 | ||
| 149 | registerRuntime: (newTools: readonly ToolRegistration[]) => | 153 | registerRuntime: (newTools: readonly ToolRegistration[]) => |
| 150 | Effect.sync(() => { | 154 | Effect.sync(() => { |
| 151 | for (const t of newTools) { | 155 | for (const t of newTools) { |
| 152 | runtimeTools.set(t.id, t); | 156 | runtimeTools.set(t.id, { |
| 157 | ...t, | ||
| 158 | inputSchema: normalizeRefs(t.inputSchema), | ||
| 159 | outputSchema: normalizeRefs(t.outputSchema), | ||
| 160 | }); | ||
| 153 | } | 161 | } |
| 154 | }), | 162 | }), |
| 155 | 163 | ||
| @@ -80,7 +80,7 @@ export { · 79 unmodified lines | |||
| 80 | } from "./plugins/in-memory-tools"; | 80 | } from "./plugins/in-memory-tools"; |
| 81 | 81 | ||
| 82 | // Schema ref utilities | 82 | // Schema ref utilities |
| 83 | export { hoistDefinitions, collectRefs, reattachDefs } from "./schema-refs"; | 83 | export { hoistDefinitions, collectRefs, reattachDefs, normalizeRefs } from "./schema-refs"; |
| 84 | 84 | ||
| 85 | // Runtime tools | 85 | // Runtime tools |
| 86 | export { | 86 | export { |
| @@ -5,7 +5,60 @@ · 4 unmodified lines | |||
| 5 | // Used by any ToolRegistry implementation (in-memory, database-backed, etc.) | 5 | // Used by any ToolRegistry implementation (in-memory, database-backed, etc.) |
| 6 | // --------------------------------------------------------------------------- | 6 | // --------------------------------------------------------------------------- |
| 7 | 7 | ||
| 8 | type JsonObj = Record<string, unknown>; | 8 | type Obj = Record<string, unknown>; |
| 9 | |||
| 10 | /** Canonical $ref prefix used internally. */ | ||
| 11 | const CANONICAL_PREFIX = "#/$defs/"; | ||
| 12 | |||
| 13 | /** Patterns we accept as equivalent $ref pointers into shared definitions. */ | ||
| 14 | const REF_PATTERN = /^#\/(?:\$defs|definitions|components\/schemas)\/(.+)$/; | ||
| 15 | |||
| 16 | /** Extract the definition name from a $ref pointer */ | ||
| 17 | const parseRefName = (ref: string): string | undefined => | ||
| 18 | ref.match(REF_PATTERN)?.[1]; | ||
| 19 | |||
| 20 | /** | ||
| 21 | * Normalize a single `$ref` string to canonical `#/$defs/<name>` form. | ||
| 22 | * Returns the string unchanged if it doesn't match a known pattern. | ||
| 23 | */ | ||
| 24 | const normalizeRef = (ref: string): string => { | ||
| 25 | const name = parseRefName(ref); | ||
| 26 | return name ? `${CANONICAL_PREFIX}${name}` : ref; | ||
| 27 | }; | ||
| 28 | |||
| 29 | /** | ||
| 30 | * Recursively rewrite all `$ref` pointers in a schema to canonical form. | ||
| 31 | * Returns the input unchanged if no rewrites are needed. | ||
| 32 | */ | ||
| 33 | export const normalizeRefs = (node: unknown): unknown => { | ||
| 34 | if (node == null || typeof node !== "object") return node; | ||
| 35 | if (Array.isArray(node)) { | ||
| 36 | let changed = false; | ||
| 37 | const out = node.map((item) => { | ||
| 38 | const n = normalizeRefs(item); | ||
| 39 | if (n !== item) changed = true; | ||
| 40 | return n; | ||
| 41 | }); | ||
| 42 | return changed ? out : node; | ||
| 43 | } | ||
| 44 | |||
| 45 | const obj = node as Obj; | ||
| 46 | |||
| 47 | // Fast path: $ref node — only rewrite the pointer, shallow copy rest | ||
| 48 | if (typeof obj.$ref === "string") { | ||
| 49 | const normalized = normalizeRef(obj.$ref); | ||
| 50 | return normalized !== obj.$ref ? { ...obj, $ref: normalized } : obj; | ||
| 51 | } | ||
| 52 | |||
| 53 | let changed = false; | ||
| 54 | const result: Obj = {}; | ||
| 55 | for (const [k, v] of Object.entries(obj)) { | ||
| 56 | const n = normalizeRefs(v); | ||
| 57 | if (n !== v) changed = true; | ||
| 58 | result[k] = n; | ||
| 59 | } | ||
| 60 | return changed ? result : obj; | ||
| 61 | }; | ||
| 9 | 62 | ||
| 10 | /** | 63 | /** |
| 11 | * Extract `$defs`, `definitions`, and `components.schemas` from a JSON Schema, | 64 | * Extract `$defs`, `definitions`, and `components.schemas` from a JSON Schema, |
| @@ -18,27 +71,27 @@ export const hoistDefinitions = ( · 6 unmodified lines | |||
| 18 | if (schema == null || typeof schema !== "object") { | 71 | if (schema == null || typeof schema !== "object") { |
| 19 | return { stripped: schema, defs: {} }; | 72 | return { stripped: schema, defs: {} }; |
| 20 | } | 73 | } |
| 21 | const obj = schema as JsonObj; | 74 | const obj = schema as Obj; |
| 22 | const defs: Record<string, unknown> = {}; | 75 | const defs: Record<string, unknown> = {}; |
| 23 | 76 | ||
| 24 | // $defs (JSON Schema draft 2019+, Effect) | 77 | // $defs (JSON Schema draft 2019+, Effect) |
| 25 | if (obj.$defs && typeof obj.$defs === "object") { | 78 | if (obj.$defs && typeof obj.$defs === "object") { |
| 26 | for (const [k, v] of Object.entries(obj.$defs as JsonObj)) { | 79 | for (const [k, v] of Object.entries(obj.$defs as Obj)) { |
| 27 | defs[k] = v; | 80 | defs[k] = v; |
| 28 | } | 81 | } |
| 29 | } | 82 | } |
| 30 | 83 | ||
| 31 | // definitions (JSON Schema draft-07) | 84 | // definitions (JSON Schema draft-07) |
| 32 | if (obj.definitions && typeof obj.definitions === "object") { | 85 | if (obj.definitions && typeof obj.definitions === "object") { |
| 33 | for (const [k, v] of Object.entries(obj.definitions as JsonObj)) { | 86 | for (const [k, v] of Object.entries(obj.definitions as Obj)) { |
| 34 | defs[k] = v; | 87 | defs[k] = v; |
| 35 | } | 88 | } |
| 36 | } | 89 | } |
| 37 | 90 | ||
| 38 | // components.schemas (OpenAPI) | 91 | // components.schemas (OpenAPI) |
| 39 | const components = obj.components as JsonObj | undefined; | 92 | const components = obj.components as Obj | undefined; |
| 40 | if (components?.schemas && typeof components.schemas === "object") { | 93 | if (components?.schemas && typeof components.schemas === "object") { |
| 41 | for (const [k, v] of Object.entries(components.schemas as JsonObj)) { | 94 | for (const [k, v] of Object.entries(components.schemas as Obj)) { |
| 42 | defs[k] = v; | 95 | defs[k] = v; |
| 43 | } | 96 | } |
| 44 | } | 97 | } |
| @@ -49,7 +102,7 @@ export const hoistDefinitions = ( · 4 unmodified lines | |||
| 49 | if (components && typeof components === "object") { | 102 | if (components && typeof components === "object") { |
| 50 | const { schemas: _s, ...otherComponents } = components; | 103 | const { schemas: _s, ...otherComponents } = components; |
| 51 | if (Object.keys(otherComponents).length > 0) { | 104 | if (Object.keys(otherComponents).length > 0) { |
| 52 | (rest as JsonObj).components = otherComponents; | 105 | (rest as Obj).components = otherComponents; |
| 53 | } | 106 | } |
| 54 | } | 107 | } |
| 55 | 108 | ||
| @@ -66,7 +119,7 @@ export const collectRefs = ( · 10 unmodified lines | |||
| 66 | found: Set<string> = new Set(), | 119 | found: Set<string> = new Set(), |
| 67 | ): Set<string> => { | 120 | ): Set<string> => { |
| 68 | if (node == null || typeof node !== "object") return found; | 121 | if (node == null || typeof node !== "object") return found; |
| 69 | const obj = node as JsonObj; | 122 | const obj = node as Obj; |
| 70 | 123 | ||
| 71 | if (typeof obj.$ref === "string") { | 124 | if (typeof obj.$ref === "string") { |
| 72 | const name = parseRefName(obj.$ref); | 125 | const name = parseRefName(obj.$ref); |
| @@ -93,6 +146,9 @@ export const collectRefs = ( · 20 unmodified lines | |||
| 93 | /** | 146 | /** |
| 94 | * Re-attach only the referenced shared definitions into a schema, | 147 | * Re-attach only the referenced shared definitions into a schema, |
| 95 | * so the caller gets a self-contained, usable JSON Schema. | 148 | * so the caller gets a self-contained, usable JSON Schema. |
| 149 | * | ||
| 150 | * Assumes all `$ref` pointers and definitions have already been normalized | ||
| 151 | * to `#/$defs/<name>` form at registration time. | ||
| 96 | */ | 152 | */ |
| 97 | export const reattachDefs = ( | 153 | export const reattachDefs = ( |
| 98 | schema: unknown, | 154 | schema: unknown, |
| @@ -108,13 +164,5 @@ export const reattachDefs = ( · 9 unmodified lines | |||
| 108 | if (def) attached[name] = def; | 164 | if (def) attached[name] = def; |
| 109 | } | 165 | } |
| 110 | 166 | ||
| 111 | return { ...(schema as JsonObj), $defs: attached }; | 167 | return { ...(schema as Record<string, unknown>), $defs: attached }; |
| 112 | }; | ||
| 113 | |||
| 114 | /** Extract the definition name from a $ref pointer */ | ||
| 115 | const parseRefName = (ref: string): string | undefined => { | ||
| 116 | const match = ref.match( | ||
| 117 | /^#\/(?:\$defs|definitions|components\/schemas)\/(.+)$/, | ||
| 118 | ); | ||
| 119 | return match?.[1]; | ||
| 120 | }; | 168 | }; |
| @@ -0,0 +1,50 @@ | |||
| 1 | { | ||
| 2 | "name": "@executor/plugin-graphql", | ||
| 3 | "private": true, | ||
| 4 | "type": "module", | ||
| 5 | "version": "0.0.0", | ||
| 6 | "exports": { | ||
| 7 | ".": "./src/sdk/index.ts", | ||
| 8 | "./api": "./src/api/index.ts", | ||
| 9 | "./react": "./src/react/index.ts" | ||
| 10 | }, | ||
| 11 | "scripts": { | ||
| 12 | "typecheck": "bunx tsc --noEmit -p tsconfig.json", | ||
| 13 | "test": "vitest run", | ||
| 14 | "test:watch": "vitest" | ||
| 15 | }, | ||
| 16 | "dependencies": { | ||
| 17 | "@effect/platform": "catalog:", | ||
| 18 | "@effect/platform-node": "catalog:", | ||
| 19 | "@executor/sdk": "workspace:*", | ||
| 20 | "effect": "catalog:" | ||
| 21 | }, | ||
| 22 | "peerDependencies": { | ||
| 23 | "react": "^19.1.0", | ||
| 24 | "@effect-atom/atom-react": "^0.5.0", | ||
| 25 | "@tanstack/react-router": "^1.168.10", | ||
| 26 | "@executor/api": "workspace:*", | ||
| 27 | "@executor/react": "workspace:*", | ||
| 28 | "@executor/ui": "workspace:*" | ||
| 29 | }, | ||
| 30 | "peerDependenciesMeta": { | ||
| 31 | "react": { "optional": true }, | ||
| 32 | "@effect-atom/atom-react": { "optional": true }, | ||
| 33 | "@tanstack/react-router": { "optional": true }, | ||
| 34 | "@executor/api": { "optional": true }, | ||
| 35 | "@executor/react": { "optional": true }, | ||
| 36 | "@executor/ui": { "optional": true } | ||
| 37 | }, | ||
| 38 | "devDependencies": { | ||
| 39 | "react": "^19.1.0", | ||
| 40 | "@effect-atom/atom-react": "^0.5.0", | ||
| 41 | "@executor/api": "workspace:*", | ||
| 42 | "@executor/react": "workspace:*", | ||
| 43 | "@executor/ui": "workspace:*", | ||
| 44 | "@types/react": "^19.1.0", | ||
| 45 | "@effect/vitest": "catalog:", | ||
| 46 | "@types/node": "catalog:", | ||
| 47 | "bun-types": "catalog:", | ||
| 48 | "vitest": "catalog:" | ||
| 49 | } | ||
| 50 | } | ||
| @@ -0,0 +1,61 @@ | |||
| 1 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; | ||
| 2 | import { Schema } from "effect"; | ||
| 3 | import { ScopeId } from "@executor/sdk"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | GraphqlIntrospectionError, | ||
| 7 | GraphqlExtractionError, | ||
| 8 | } from "../sdk/errors"; | ||
| 9 | |||
| 10 | // --------------------------------------------------------------------------- | ||
| 11 | // Params | ||
| 12 | // --------------------------------------------------------------------------- | ||
| 13 | |||
| 14 | const scopeIdParam = HttpApiSchema.param("scopeId", ScopeId); | ||
| 15 | |||
| 16 | // --------------------------------------------------------------------------- | ||
| 17 | // Payloads | ||
| 18 | // --------------------------------------------------------------------------- | ||
| 19 | |||
| 20 | const AddSourcePayload = Schema.Struct({ | ||
| 21 | endpoint: Schema.String, | ||
| 22 | introspectionJson: Schema.optional(Schema.String), | ||
| 23 | namespace: Schema.optional(Schema.String), | ||
| 24 | headers: Schema.optional( | ||
| 25 | Schema.Record({ key: Schema.String, value: Schema.Unknown }), | ||
| 26 | ), | ||
| 27 | }); | ||
| 28 | |||
| 29 | // --------------------------------------------------------------------------- | ||
| 30 | // Responses | ||
| 31 | // --------------------------------------------------------------------------- | ||
| 32 | |||
| 33 | const AddSourceResponse = Schema.Struct({ | ||
| 34 | toolCount: Schema.Number, | ||
| 35 | namespace: Schema.String, | ||
| 36 | }); | ||
| 37 | |||
| 38 | // --------------------------------------------------------------------------- | ||
| 39 | // Errors with HTTP status | ||
| 40 | // --------------------------------------------------------------------------- | ||
| 41 | |||
| 42 | const IntrospectionError = GraphqlIntrospectionError.annotations( | ||
| 43 | HttpApiSchema.annotations({ status: 400 }), | ||
| 44 | ); | ||
| 45 | const ExtractionError = GraphqlExtractionError.annotations( | ||
| 46 | HttpApiSchema.annotations({ status: 400 }), | ||
| 47 | ); | ||
| 48 | |||
| 49 | // --------------------------------------------------------------------------- | ||
| 50 | // Group | ||
| 51 | // --------------------------------------------------------------------------- | ||
| 52 | |||
| 53 | export class GraphqlGroup extends HttpApiGroup.make("graphql") | ||
| 54 | .add( | ||
| 55 | HttpApiEndpoint.post("addSource")`/scopes/${scopeIdParam}/graphql/sources` | ||
| 56 | .setPayload(AddSourcePayload) | ||
| 57 | .addSuccess(AddSourceResponse) | ||
| 58 | .addError(IntrospectionError) | ||
| 59 | .addError(ExtractionError), | ||
| 60 | ) | ||
| 61 | .prefix("/v1") {} | ||
| @@ -0,0 +1,45 @@ | |||
| 1 | import { HttpApiBuilder } from "@effect/platform"; | ||
| 2 | import { Context, Effect } from "effect"; | ||
| 3 | |||
| 4 | import { addGroup } from "@executor/api"; | ||
| 5 | import type { GraphqlPluginExtension, HeaderValue } from "../sdk/plugin"; | ||
| 6 | import { GraphqlGroup } from "./group"; | ||
| 7 | |||
| 8 | // --------------------------------------------------------------------------- | ||
| 9 | // Service tag — the server provides the GraphQL extension | ||
| 10 | // --------------------------------------------------------------------------- | ||
| 11 | |||
| 12 | export class GraphqlExtensionService extends Context.Tag( | ||
| 13 | "GraphqlExtensionService", | ||
| 14 | )<GraphqlExtensionService, GraphqlPluginExtension>() {} | ||
| 15 | |||
| 16 | // --------------------------------------------------------------------------- | ||
| 17 | // Composed API — core + graphql group | ||
| 18 | // --------------------------------------------------------------------------- | ||
| 19 | |||
| 20 | const ExecutorApiWithGraphql = addGroup(GraphqlGroup); | ||
| 21 | |||
| 22 | // --------------------------------------------------------------------------- | ||
| 23 | // Handlers | ||
| 24 | // --------------------------------------------------------------------------- | ||
| 25 | |||
| 26 | export const GraphqlHandlers = HttpApiBuilder.group( | ||
| 27 | ExecutorApiWithGraphql, | ||
| 28 | "graphql", | ||
| 29 | (handlers) => | ||
| 30 | handlers.handle("addSource", ({ payload }) => | ||
| 31 | Effect.gen(function* () { | ||
| 32 | const ext = yield* GraphqlExtensionService; | ||
| 33 | const result = yield* ext.addSource({ | ||
| 34 | endpoint: payload.endpoint, | ||
| 35 | introspectionJson: payload.introspectionJson, | ||
| 36 | namespace: payload.namespace, | ||
| 37 | headers: payload.headers as Record<string, HeaderValue> | undefined, | ||
| 38 | }); | ||
| 39 | return { | ||
| 40 | toolCount: result.toolCount, | ||
| 41 | namespace: payload.namespace ?? "graphql", | ||
| 42 | }; | ||
| 43 | }).pipe(Effect.orDie), | ||
| 44 | ), | ||
| 45 | ); | ||
| @@ -0,0 +1,2 @@ | |||
| 1 | export { GraphqlGroup } from "./group"; | ||
| 2 | export { GraphqlHandlers, GraphqlExtensionService } from "./handlers"; | ||
| @@ -0,0 +1,275 @@ | |||
| 1 | import { useState } from "react"; | ||
| 2 | import { useAtomSet, useAtomValue, useAtomRefresh, Result } from "@effect-atom/atom-react"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | secretsAtom, | ||
| 6 | setSecret, | ||
| 7 | ScopeId, | ||
| 8 | SecretPicker, | ||
| 9 | type SecretPickerSecret, | ||
| 10 | } from "@executor/react"; | ||
| 11 | import { SecretId } from "@executor/sdk"; | ||
| 12 | import { Button } from "@executor/ui/components/button"; | ||
| 13 | import { Input } from "@executor/ui/components/input"; | ||
| 14 | import { Label } from "@executor/ui/components/label"; | ||
| 15 | import { Spinner } from "@executor/ui/components/spinner"; | ||
| 16 | import { addGraphqlSource } from "./atoms"; | ||
| 17 | import type { HeaderValue } from "../sdk/types"; | ||
| 18 | |||
| 19 | // --------------------------------------------------------------------------- | ||
| 20 | // Inline secret creation | ||
| 21 | // --------------------------------------------------------------------------- | ||
| 22 | |||
| 23 | function InlineCreateSecret(props: { | ||
| 24 | headerName: string; | ||
| 25 | suggestedId: string; | ||
| 26 | onCreated: (secretId: string) => void; | ||
| 27 | onCancel: () => void; | ||
| 28 | }) { | ||
| 29 | const [secretId, setSecretId] = useState(props.suggestedId); | ||
| 30 | const [secretName, setSecretName] = useState(props.headerName); | ||
| 31 | const [secretValue, setSecretValue] = useState(""); | ||
| 32 | const [saving, setSaving] = useState(false); | ||
| 33 | const [error, setError] = useState<string | null>(null); | ||
| 34 | const doSet = useAtomSet(setSecret, { mode: "promise" }); | ||
| 35 | const refreshSecrets = useAtomRefresh(secretsAtom()); | ||
| 36 | |||
| 37 | const handleSave = async () => { | ||
| 38 | if (!secretId.trim() || !secretValue.trim()) return; | ||
| 39 | setSaving(true); | ||
| 40 | setError(null); | ||
| 41 | try { | ||
| 42 | await doSet({ | ||
| 43 | path: { scopeId: ScopeId.make("default") }, | ||
| 44 | payload: { | ||
| 45 | id: SecretId.make(secretId.trim()), | ||
| 46 | name: secretName.trim() || secretId.trim(), | ||
| 47 | value: secretValue.trim(), | ||
| 48 | purpose: `Auth header: ${props.headerName}`, | ||
| 49 | }, | ||
| 50 | }); | ||
| 51 | refreshSecrets(); | ||
| 52 | props.onCreated(secretId.trim()); | ||
| 53 | } catch (e) { | ||
| 54 | setError(e instanceof Error ? e.message : "Failed to save secret"); | ||
| 55 | setSaving(false); | ||
| 56 | } | ||
| 57 | }; | ||
| 58 | |||
| 59 | return ( | ||
| 60 | <div className="rounded-lg border border-primary/20 bg-primary/[0.02] p-3 space-y-2.5"> | ||
| 61 | <p className="text-[11px] font-semibold text-primary tracking-wide uppercase">New secret</p> | ||
| 62 | <div className="grid grid-cols-2 gap-2"> | ||
| 63 | <div className="space-y-1"> | ||
| 64 | <Label className="text-[10px] uppercase tracking-wider text-muted-foreground">ID</Label> | ||
| 65 | <Input | ||
| 66 | value={secretId} | ||
| 67 | onChange={(e) => setSecretId((e.target as HTMLInputElement).value)} | ||
| 68 | placeholder="my-api-token" | ||
| 69 | className="h-8 text-xs font-mono" | ||
| 70 | /> | ||
| 71 | </div> | ||
| 72 | <div className="space-y-1"> | ||
| 73 | <Label className="text-[10px] uppercase tracking-wider text-muted-foreground">Label</Label> | ||
| 74 | <Input | ||
| 75 | value={secretName} | ||
| 76 | onChange={(e) => setSecretName((e.target as HTMLInputElement).value)} | ||
| 77 | placeholder="API Token" | ||
| 78 | className="h-8 text-xs" | ||
| 79 | /> | ||
| 80 | </div> | ||
| 81 | </div> | ||
| 82 | <div className="space-y-1"> | ||
| 83 | <Label className="text-[10px] uppercase tracking-wider text-muted-foreground">Value</Label> | ||
| 84 | <Input | ||
| 85 | type="password" | ||
| 86 | value={secretValue} | ||
| 87 | onChange={(e) => setSecretValue((e.target as HTMLInputElement).value)} | ||
| 88 | placeholder="paste your token or key..." | ||
| 89 | className="h-8 text-xs font-mono" | ||
| 90 | /> | ||
| 91 | </div> | ||
| 92 | {error && <p className="text-[11px] text-destructive">{error}</p>} | ||
| 93 | <div className="flex gap-1.5 pt-0.5"> | ||
| 94 | <Button variant="outline" size="xs" onClick={props.onCancel}> | ||
| 95 | Cancel | ||
| 96 | </Button> | ||
| 97 | <Button | ||
| 98 | size="xs" | ||
| 99 | onClick={handleSave} | ||
| 100 | disabled={!secretId.trim() || !secretValue.trim() || saving} | ||
| 101 | > | ||
| 102 | {saving ? "Saving..." : "Create & use"} | ||
| 103 | </Button> | ||
| 104 | </div> | ||
| 105 | </div> | ||
| 106 | ); | ||
| 107 | } | ||
| 108 | |||
| 109 | // --------------------------------------------------------------------------- | ||
| 110 | // Auth header row | ||
| 111 | // --------------------------------------------------------------------------- | ||
| 112 | |||
| 113 | function AuthHeaderRow(props: { | ||
| 114 | selectedSecretId: string | null; | ||
| 115 | onSelect: (secretId: string) => void; | ||
| 116 | existingSecrets: readonly SecretPickerSecret[]; | ||
| 117 | }) { | ||
| 118 | const [creating, setCreating] = useState(false); | ||
| 119 | const { selectedSecretId, onSelect, existingSecrets } = props; | ||
| 120 | |||
| 121 | if (creating) { | ||
| 122 | return ( | ||
| 123 | <InlineCreateSecret | ||
| 124 | headerName="Authorization" | ||
| 125 | suggestedId="graphql-auth-token" | ||
| 126 | onCreated={(id) => { | ||
| 127 | onSelect(id); | ||
| 128 | setCreating(false); | ||
| 129 | }} | ||
| 130 | onCancel={() => setCreating(false)} | ||
| 131 | /> | ||
| 132 | ); | ||
| 133 | } | ||
| 134 | |||
| 135 | return ( | ||
| 136 | <div className="space-y-1.5"> | ||
| 137 | <div className="flex items-center gap-1.5"> | ||
| 138 | <div className="flex-1 min-w-0"> | ||
| 139 | <SecretPicker | ||
| 140 | value={selectedSecretId} | ||
| 141 | onSelect={onSelect} | ||
| 142 | secrets={existingSecrets} | ||
| 143 | /> | ||
| 144 | </div> | ||
| 145 | <Button variant="outline" size="sm" className="shrink-0" onClick={() => setCreating(true)}> | ||
| 146 | + New | ||
| 147 | </Button> | ||
| 148 | </div> | ||
| 149 | </div> | ||
| 150 | ); | ||
| 151 | } | ||
| 152 | |||
| 153 | // --------------------------------------------------------------------------- | ||
| 154 | // Main component | ||
| 155 | // --------------------------------------------------------------------------- | ||
| 156 | |||
| 157 | export default function AddGraphqlSource(props: { | ||
| 158 | onComplete: () => void; | ||
| 159 | onCancel: () => void; | ||
| 160 | }) { | ||
| 161 | const [endpoint, setEndpoint] = useState(""); | ||
| 162 | const [namespace, setNamespace] = useState(""); | ||
| 163 | const [authSecretId, setAuthSecretId] = useState<string | null>(null); | ||
| 164 | const [adding, setAdding] = useState(false); | ||
| 165 | const [addError, setAddError] = useState<string | null>(null); | ||
| 166 | |||
| 167 | const doAdd = useAtomSet(addGraphqlSource, { mode: "promise" }); | ||
| 168 | const secrets = useAtomValue(secretsAtom()); | ||
| 169 | |||
| 170 | const secretList: readonly SecretPickerSecret[] = Result.match(secrets, { | ||
| 171 | onInitial: () => [] as SecretPickerSecret[], | ||
| 172 | onFailure: () => [] as SecretPickerSecret[], | ||
| 173 | onSuccess: ({ value }) => | ||
| 174 | value.map((s) => ({ | ||
| 175 | id: s.id, | ||
| 176 | name: s.name, | ||
| 177 | provider: s.provider ? String(s.provider) : undefined, | ||
| 178 | })), | ||
| 179 | }); | ||
| 180 | |||
| 181 | const canAdd = endpoint.trim().length > 0; | ||
| 182 | |||
| 183 | const handleAdd = async () => { | ||
| 184 | setAdding(true); | ||
| 185 | setAddError(null); | ||
| 186 | try { | ||
| 187 | const headers: Record<string, HeaderValue> = {}; | ||
| 188 | if (authSecretId) { | ||
| 189 | headers["Authorization"] = { secretId: authSecretId, prefix: "Bearer " }; | ||
| 190 | } | ||
| 191 | |||
| 192 | await doAdd({ | ||
| 193 | path: { scopeId: "default" as never }, | ||
| 194 | payload: { | ||
| 195 | endpoint: endpoint.trim(), | ||
| 196 | namespace: namespace.trim() || undefined, | ||
| 197 | ...(Object.keys(headers).length > 0 ? { headers } : {}), | ||
| 198 | }, | ||
| 199 | }); | ||
| 200 | props.onComplete(); | ||
| 201 | } catch (e) { | ||
| 202 | setAddError(e instanceof Error ? e.message : "Failed to add source"); | ||
| 203 | setAdding(false); | ||
| 204 | } | ||
| 205 | }; | ||
| 206 | |||
| 207 | return ( | ||
| 208 | <div className="space-y-6"> | ||
| 209 | <h1 className="text-xl font-semibold text-foreground">Add GraphQL Source</h1> | ||
| 210 | |||
| 211 | {/* Endpoint */} | ||
| 212 | <section className="space-y-2"> | ||
| 213 | <Label>GraphQL Endpoint</Label> | ||
| 214 | <Input | ||
| 215 | value={endpoint} | ||
| 216 | onChange={(e) => setEndpoint((e.target as HTMLInputElement).value)} | ||
| 217 | placeholder="https://api.example.com/graphql" | ||
| 218 | className="font-mono text-sm" | ||
| 219 | /> | ||
| 220 | <p className="text-[12px] text-muted-foreground"> | ||
| 221 | The endpoint will be introspected to discover available queries and mutations. | ||
| 222 | </p> | ||
| 223 | </section> | ||
| 224 | |||
| 225 | {/* Namespace */} | ||
| 226 | <section className="space-y-2"> | ||
| 227 | <Label> | ||
| 228 | Namespace <span className="text-muted-foreground font-normal">(optional)</span> | ||
| 229 | </Label> | ||
| 230 | <Input | ||
| 231 | value={namespace} | ||
| 232 | onChange={(e) => setNamespace((e.target as HTMLInputElement).value)} | ||
| 233 | placeholder="my_api" | ||
| 234 | className="font-mono text-sm" | ||
| 235 | /> | ||
| 236 | <p className="text-[12px] text-muted-foreground"> | ||
| 237 | A prefix for the tool names. Derived from the endpoint hostname if not provided. | ||
| 238 | </p> | ||
| 239 | </section> | ||
| 240 | |||
| 241 | {/* Authentication */} | ||
| 242 | <section className="space-y-2.5"> | ||
| 243 | <Label> | ||
| 244 | Authentication <span className="text-muted-foreground font-normal">(optional)</span> | ||
| 245 | </Label> | ||
| 246 | <p className="text-[12px] text-muted-foreground"> | ||
| 247 | Select a secret for the Bearer token sent with every request, including introspection. | ||
| 248 | </p> | ||
| 249 | <AuthHeaderRow | ||
| 250 | selectedSecretId={authSecretId} | ||
| 251 | onSelect={setAuthSecretId} | ||
| 252 | existingSecrets={secretList} | ||
| 253 | /> | ||
| 254 | </section> | ||
| 255 | |||
| 256 | {/* Error */} | ||
| 257 | {addError && ( | ||
| 258 | <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2"> | ||
| 259 | <p className="text-[12px] text-destructive">{addError}</p> | ||
| 260 | </div> | ||
| 261 | )} | ||
| 262 | |||
| 263 | {/* Actions */} | ||
| 264 | <div className="flex items-center justify-between border-t border-border pt-4"> | ||
| 265 | <Button variant="ghost" onClick={props.onCancel} disabled={adding}> | ||
| 266 | Cancel | ||
| 267 | </Button> | ||
| 268 | <Button onClick={handleAdd} disabled={!canAdd || adding}> | ||
| 269 | {adding && <Spinner className="size-3.5" />} | ||
| 270 | {adding ? "Adding..." : "Add source"} | ||
| 271 | </Button> | ||
| 272 | </div> | ||
| 273 | </div> | ||
| 274 | ); | ||
| 275 | } | ||
| @@ -0,0 +1,12 @@ | |||
| 1 | export default function EditGraphqlSource(props: { | ||
| 2 | sourceId: string; | ||
| 3 | onSave: () => void; | ||
| 4 | }) { | ||
| 5 | return ( | ||
| 6 | <div> | ||
| 7 | <h3>Edit GraphQL Source</h3> | ||
| 8 | <p>Source: {props.sourceId}</p> | ||
| 9 | <button onClick={props.onSave}>Save</button> | ||
| 10 | </div> | ||
| 11 | ); | ||
| 12 | } | ||
| @@ -0,0 +1,9 @@ | |||
| 1 | export default function GraphqlSourceSummary(props: { | ||
| 2 | sourceId: string; | ||
| 3 | }) { | ||
| 4 | return ( | ||
| 5 | <span> | ||
| 6 | GraphQL · {props.sourceId} | ||
| 7 | </span> | ||
| 8 | ); | ||
| 9 | } | ||
| @@ -0,0 +1,7 @@ | |||
| 1 | import { GraphqlClient } from "./client"; | ||
| 2 | |||
| 3 | // --------------------------------------------------------------------------- | ||
| 4 | // Mutation atoms | ||
| 5 | // --------------------------------------------------------------------------- | ||
| 6 | |||
| 7 | export const addGraphqlSource = GraphqlClient.mutation("graphql", "addSource"); | ||
| @@ -0,0 +1,20 @@ | |||
| 1 | import { AtomHttpApi } from "@effect-atom/atom-react"; | ||
| 2 | import { FetchHttpClient } from "@effect/platform"; | ||
| 3 | import { addGroup } from "@executor/api"; | ||
| 4 | import { getBaseUrl } from "@executor/react"; | ||
| 5 | import { GraphqlGroup } from "../api/group"; | ||
| 6 | |||
| 7 | // --------------------------------------------------------------------------- | ||
| 8 | // GraphQL-aware client — core routes + graphql routes | ||
| 9 | // --------------------------------------------------------------------------- | ||
| 10 | |||
| 11 | const GraphqlApi = addGroup(GraphqlGroup); | ||
| 12 | |||
| 13 | export const GraphqlClient = AtomHttpApi.Tag<"GraphqlClient">()( | ||
| 14 | "GraphqlClient", | ||
| 15 | { | ||
| 16 | api: GraphqlApi, | ||
| 17 | httpClient: FetchHttpClient.layer, | ||
| 18 | baseUrl: getBaseUrl(), | ||
| 19 | }, | ||
| 20 | ); | ||
| @@ -0,0 +1,3 @@ | |||
| 1 | export { graphqlSourcePlugin } from "./source-plugin"; | ||
| 2 | export { GraphqlClient } from "./client"; | ||
| 3 | export { addGraphqlSource } from "./atoms"; | ||
| @@ -0,0 +1,14 @@ | |||
| 1 | import { lazy } from "react"; | ||
| 2 | import type { SourcePlugin } from "@executor/react"; | ||
| 3 | |||
| 4 | // --------------------------------------------------------------------------- | ||
| 5 | // GraphQL source plugin — lazy-loaded components | ||
| 6 | // --------------------------------------------------------------------------- | ||
| 7 | |||
| 8 | export const graphqlSourcePlugin: SourcePlugin = { | ||
| 9 | key: "graphql", | ||
| 10 | label: "GraphQL", | ||
| 11 | add: lazy(() => import("./AddGraphqlSource")), | ||
| 12 | edit: lazy(() => import("./EditGraphqlSource")), | ||
| 13 | summary: lazy(() => import("./GraphqlSourceSummary")), | ||
| 14 | }; | ||
| @@ -0,0 +1,25 @@ | |||
| 1 | import { Schema } from "effect"; | ||
| 2 | |||
| 3 | export class GraphqlIntrospectionError extends Schema.TaggedError<GraphqlIntrospectionError>()( | ||
| 4 | "GraphqlIntrospectionError", | ||
| 5 | { | ||
| 6 | message: Schema.String, | ||
| 7 | error: Schema.Defect, | ||
| 8 | }, | ||
| 9 | ) {} | ||
| 10 | |||
| 11 | export class GraphqlExtractionError extends Schema.TaggedError<GraphqlExtractionError>()( | ||
| 12 | "GraphqlExtractionError", | ||
| 13 | { | ||
| 14 | message: Schema.String, | ||
| 15 | }, | ||
| 16 | ) {} | ||
| 17 | |||
| 18 | export class GraphqlInvocationError extends Schema.TaggedError<GraphqlInvocationError>()( | ||
| 19 | "GraphqlInvocationError", | ||
| 20 | { | ||
| 21 | message: Schema.String, | ||
| 22 | statusCode: Schema.optionalWith(Schema.Number, { as: "Option" }), | ||
| 23 | error: Schema.Defect, | ||
| 24 | }, | ||
| 25 | ) {} | ||
| @@ -0,0 +1,260 @@ | |||
| 1 | import { describe, it, expect } from "@effect/vitest"; | ||
| 2 | import { Effect } from "effect"; | ||
| 3 | |||
| 4 | import { extract } from "./extract"; | ||
| 5 | import type { IntrospectionResult } from "./introspect"; | ||
| 6 | |||
| 7 | // --------------------------------------------------------------------------- | ||
| 8 | // Minimal introspection fixture | ||
| 9 | // --------------------------------------------------------------------------- | ||
| 10 | |||
| 11 | const makeIntrospection = (overrides?: Partial<IntrospectionResult["__schema"]>): IntrospectionResult => ({ | ||
| 12 | __schema: { | ||
| 13 | queryType: { name: "Query" }, | ||
| 14 | mutationType: { name: "Mutation" }, | ||
| 15 | types: [ | ||
| 16 | { | ||
| 17 | kind: "OBJECT", | ||
| 18 | name: "Query", | ||
| 19 | description: null, | ||
| 20 | fields: [ | ||
| 21 | { | ||
| 22 | name: "user", | ||
| 23 | description: "Fetch a user by ID", | ||
| 24 | args: [ | ||
| 25 | { | ||
| 26 | name: "id", | ||
| 27 | description: "User ID", | ||
| 28 | type: { kind: "NON_NULL", name: null, ofType: { kind: "SCALAR", name: "ID", ofType: null } }, | ||
| 29 | defaultValue: null, | ||
| 30 | }, | ||
| 31 | ], | ||
| 32 | type: { kind: "OBJECT", name: "User", ofType: null }, | ||
| 33 | }, | ||
| 34 | { | ||
| 35 | name: "users", | ||
| 36 | description: "List all users", | ||
| 37 | args: [], | ||
| 38 | type: { | ||
| 39 | kind: "NON_NULL", | ||
| 40 | name: null, | ||
| 41 | ofType: { | ||
| 42 | kind: "LIST", | ||
| 43 | name: null, | ||
| 44 | ofType: { kind: "OBJECT", name: "User", ofType: null }, | ||
| 45 | }, | ||
| 46 | }, | ||
| 47 | }, | ||
| 48 | ], | ||
| 49 | inputFields: null, | ||
| 50 | enumValues: null, | ||
| 51 | }, | ||
| 52 | { | ||
| 53 | kind: "OBJECT", | ||
| 54 | name: "Mutation", | ||
| 55 | description: null, | ||
| 56 | fields: [ | ||
| 57 | { | ||
| 58 | name: "createUser", | ||
| 59 | description: "Create a new user", | ||
| 60 | args: [ | ||
| 61 | { | ||
| 62 | name: "name", | ||
| 63 | description: null, | ||
| 64 | type: { kind: "NON_NULL", name: null, ofType: { kind: "SCALAR", name: "String", ofType: null } }, | ||
| 65 | defaultValue: null, | ||
| 66 | }, | ||
| 67 | { | ||
| 68 | name: "email", | ||
| 69 | description: null, | ||
| 70 | type: { kind: "SCALAR", name: "String", ofType: null }, | ||
| 71 | defaultValue: null, | ||
| 72 | }, | ||
| 73 | ], | ||
| 74 | type: { kind: "OBJECT", name: "User", ofType: null }, | ||
| 75 | }, | ||
| 76 | ], | ||
| 77 | inputFields: null, | ||
| 78 | enumValues: null, | ||
| 79 | }, | ||
| 80 | { | ||
| 81 | kind: "OBJECT", | ||
| 82 | name: "User", | ||
| 83 | description: null, | ||
| 84 | fields: [ | ||
| 85 | { name: "id", description: null, args: [], type: { kind: "NON_NULL", name: null, ofType: { kind: "SCALAR", name: "ID", ofType: null } } }, | ||
| 86 | { name: "name", description: null, args: [], type: { kind: "SCALAR", name: "String", ofType: null } }, | ||
| 87 | { name: "email", description: null, args: [], type: { kind: "SCALAR", name: "String", ofType: null } }, | ||
| 88 | ], | ||
| 89 | inputFields: null, | ||
| 90 | enumValues: null, | ||
| 91 | }, | ||
| 92 | { kind: "SCALAR", name: "ID", description: null, fields: null, inputFields: null, enumValues: null }, | ||
| 93 | { kind: "SCALAR", name: "String", description: null, fields: null, inputFields: null, enumValues: null }, | ||
| 94 | { kind: "SCALAR", name: "Boolean", description: null, fields: null, inputFields: null, enumValues: null }, | ||
| 95 | ], | ||
| 96 | ...overrides, | ||
| 97 | }, | ||
| 98 | }); | ||
| 99 | |||
| 100 | // --------------------------------------------------------------------------- | ||
| 101 | // Tests | ||
| 102 | // --------------------------------------------------------------------------- | ||
| 103 | |||
| 104 | describe("extract", () => { | ||
| 105 | it("extracts queries and mutations from introspection result", async () => { | ||
| 106 | const { result } = await Effect.runPromise(extract(makeIntrospection())); | ||
| 107 | |||
| 108 | const queries = result.fields.filter((f) => f.kind === "query"); | ||
| 109 | const mutations = result.fields.filter((f) => f.kind === "mutation"); | ||
| 110 | |||
| 111 | expect(queries).toHaveLength(2); | ||
| 112 | expect(mutations).toHaveLength(1); | ||
| 113 | |||
| 114 | const userQuery = queries.find((f) => f.fieldName === "user"); | ||
| 115 | expect(userQuery).toBeDefined(); | ||
| 116 | expect(userQuery!.arguments).toHaveLength(1); | ||
| 117 | expect(userQuery!.arguments[0].name).toBe("id"); | ||
| 118 | expect(userQuery!.arguments[0].required).toBe(true); | ||
| 119 | expect(userQuery!.returnTypeName).toBe("User"); | ||
| 120 | |||
| 121 | const createMutation = mutations.find((f) => f.fieldName === "createUser"); | ||
| 122 | expect(createMutation).toBeDefined(); | ||
| 123 | expect(createMutation!.arguments).toHaveLength(2); | ||
| 124 | expect(createMutation!.arguments[0].name).toBe("name"); | ||
| 125 | expect(createMutation!.arguments[0].required).toBe(true); | ||
| 126 | expect(createMutation!.arguments[1].name).toBe("email"); | ||
| 127 | expect(createMutation!.arguments[1].required).toBe(false); | ||
| 128 | }); | ||
| 129 | |||
| 130 | it("builds input schema with required fields", async () => { | ||
| 131 | const { result } = await Effect.runPromise(extract(makeIntrospection())); | ||
| 132 | const createUser = result.fields.find((f) => f.fieldName === "createUser"); | ||
| 133 | expect(createUser).toBeDefined(); | ||
| 134 | |||
| 135 | const inputSchema = createUser!.inputSchema as any; | ||
| 136 | expect(inputSchema.value).toBeDefined(); | ||
| 137 | const schema = inputSchema.value; | ||
| 138 | expect(schema.type).toBe("object"); | ||
| 139 | expect(schema.properties.name.type).toBe("string"); | ||
| 140 | expect(schema.required).toContain("name"); | ||
| 141 | expect(schema.required).not.toContain("email"); | ||
| 142 | }); | ||
| 143 | |||
| 144 | it("handles schema with no mutations", async () => { | ||
| 145 | const { result } = await Effect.runPromise( | ||
| 146 | extract(makeIntrospection({ mutationType: null })), | ||
| 147 | ); | ||
| 148 | |||
| 149 | const mutations = result.fields.filter((f) => f.kind === "mutation"); | ||
| 150 | expect(mutations).toHaveLength(0); | ||
| 151 | }); | ||
| 152 | |||
| 153 | it("handles empty query type", async () => { | ||
| 154 | const { result } = await Effect.runPromise( | ||
| 155 | extract(makeIntrospection({ queryType: null })), | ||
| 156 | ); | ||
| 157 | |||
| 158 | const queries = result.fields.filter((f) => f.kind === "query"); | ||
| 159 | expect(queries).toHaveLength(0); | ||
| 160 | }); | ||
| 161 | |||
| 162 | it("handles circular input type references without stack overflow", async () => { | ||
| 163 | const circular: IntrospectionResult = { | ||
| 164 | __schema: { | ||
| 165 | queryType: { name: "Query" }, | ||
| 166 | mutationType: null, | ||
| 167 | types: [ | ||
| 168 | { | ||
| 169 | kind: "OBJECT", | ||
| 170 | name: "Query", | ||
| 171 | description: null, | ||
| 172 | fields: [ | ||
| 173 | { | ||
| 174 | name: "issues", | ||
| 175 | description: "List issues", | ||
| 176 | args: [ | ||
| 177 | { | ||
| 178 | name: "filter", | ||
| 179 | description: null, | ||
| 180 | type: { kind: "INPUT_OBJECT", name: "IssueFilter", ofType: null }, | ||
| 181 | defaultValue: null, | ||
| 182 | }, | ||
| 183 | ], | ||
| 184 | type: { kind: "OBJECT", name: "Issue", ofType: null }, | ||
| 185 | }, | ||
| 186 | ], | ||
| 187 | inputFields: null, | ||
| 188 | enumValues: null, | ||
| 189 | }, | ||
| 190 | { | ||
| 191 | kind: "INPUT_OBJECT", | ||
| 192 | name: "IssueFilter", | ||
| 193 | description: null, | ||
| 194 | fields: null, | ||
| 195 | inputFields: [ | ||
| 196 | { | ||
| 197 | name: "title", | ||
| 198 | description: null, | ||
| 199 | type: { kind: "SCALAR", name: "String", ofType: null }, | ||
| 200 | defaultValue: null, | ||
| 201 | }, | ||
| 202 | { | ||
| 203 | name: "and", | ||
| 204 | description: "Compound filter", | ||
| 205 | type: { | ||
| 206 | kind: "LIST", | ||
| 207 | name: null, | ||
| 208 | ofType: { kind: "INPUT_OBJECT", name: "IssueFilter", ofType: null }, | ||
| 209 | }, | ||
| 210 | defaultValue: null, | ||
| 211 | }, | ||
| 212 | { | ||
| 213 | name: "or", | ||
| 214 | description: null, | ||
| 215 | type: { | ||
| 216 | kind: "LIST", | ||
| 217 | name: null, | ||
| 218 | ofType: { kind: "INPUT_OBJECT", name: "IssueFilter", ofType: null }, | ||
| 219 | }, | ||
| 220 | defaultValue: null, | ||
| 221 | }, | ||
| 222 | ], | ||
| 223 | enumValues: null, | ||
| 224 | }, | ||
| 225 | { | ||
| 226 | kind: "OBJECT", | ||
| 227 | name: "Issue", | ||
| 228 | description: null, | ||
| 229 | fields: [ | ||
| 230 | { name: "id", description: null, args: [], type: { kind: "SCALAR", name: "ID", ofType: null } }, | ||
| 231 | ], | ||
| 232 | inputFields: null, | ||
| 233 | enumValues: null, | ||
| 234 | }, | ||
| 235 | { kind: "SCALAR", name: "String", description: null, fields: null, inputFields: null, enumValues: null }, | ||
| 236 | { kind: "SCALAR", name: "ID", description: null, fields: null, inputFields: null, enumValues: null }, | ||
| 237 | ], | ||
| 238 | }, | ||
| 239 | }; | ||
| 240 | |||
| 241 | // Should not throw "Maximum call stack size exceeded" | ||
| 242 | const { result, definitions } = await Effect.runPromise(extract(circular)); | ||
| 243 | expect(result.fields).toHaveLength(1); | ||
| 244 | |||
| 245 | const issuesField = result.fields[0]; | ||
| 246 | expect(issuesField.fieldName).toBe("issues"); | ||
| 247 | |||
| 248 | // The filter arg should use a $ref, not inline the full type | ||
| 249 | const schema = (issuesField.inputSchema as any).value; | ||
| 250 | expect(schema.properties.filter.$ref).toBe("#/$defs/IssueFilter"); | ||
| 251 | |||
| 252 | // The definition should exist with proper fields | ||
| 253 | const filterDef = definitions["IssueFilter"] as any; | ||
| 254 | expect(filterDef.type).toBe("object"); | ||
| 255 | expect(filterDef.properties.title.type).toBe("string"); | ||
| 256 | // Self-referential "and" field uses $ref back to itself | ||
| 257 | expect(filterDef.properties.and.type).toBe("array"); | ||
| 258 | expect(filterDef.properties.and.items.$ref).toBe("#/$defs/IssueFilter"); | ||
| 259 | }); | ||
| 260 | }); | ||
| @@ -0,0 +1,270 @@ | |||
| 1 | import { Effect, Option } from "effect"; | ||
| 2 | |||
| 3 | import { GraphqlExtractionError } from "./errors"; | ||
| 4 | import type { | ||
| 5 | IntrospectionResult, | ||
| 6 | IntrospectionSchema, | ||
| 7 | IntrospectionType, | ||
| 8 | IntrospectionTypeRef, | ||
| 9 | IntrospectionInputValue, | ||
| 10 | } from "./introspect"; | ||
| 11 | import { | ||
| 12 | ExtractedField, | ||
| 13 | ExtractionResult, | ||
| 14 | GraphqlArgument, | ||
| 15 | type GraphqlOperationKind, | ||
| 16 | } from "./types"; | ||
| 17 | |||
| 18 | // --------------------------------------------------------------------------- | ||
| 19 | // Type ref helpers | ||
| 20 | // --------------------------------------------------------------------------- | ||
| 21 | |||
| 22 | /** Unwrap NON_NULL / LIST wrappers to get the leaf type name */ | ||
| 23 | const unwrapTypeName = (ref: IntrospectionTypeRef): string => { | ||
| 24 | if (ref.name) return ref.name; | ||
| 25 | if (ref.ofType) return unwrapTypeName(ref.ofType); | ||
| 26 | return "Unknown"; | ||
| 27 | }; | ||
| 28 | |||
| 29 | /** Check if a type ref is non-null (required) */ | ||
| 30 | const isNonNull = (ref: IntrospectionTypeRef): boolean => | ||
| 31 | ref.kind === "NON_NULL"; | ||
| 32 | |||
| 33 | // --------------------------------------------------------------------------- | ||
| 34 | // Build shared definitions from all INPUT_OBJECT and ENUM types | ||
| 35 | // --------------------------------------------------------------------------- | ||
| 36 | |||
| 37 | const buildDefinitions = ( | ||
| 38 | types: ReadonlyMap<string, IntrospectionType>, | ||
| 39 | ): Record<string, unknown> => { | ||
| 40 | const defs: Record<string, unknown> = {}; | ||
| 41 | |||
| 42 | for (const [name, type] of types) { | ||
| 43 | // Skip internal types | ||
| 44 | if (name.startsWith("__")) continue; | ||
| 45 | |||
| 46 | if (type.kind === "INPUT_OBJECT" && type.inputFields) { | ||
| 47 | const properties: Record<string, unknown> = {}; | ||
| 48 | const required: string[] = []; | ||
| 49 | |||
| 50 | for (const field of type.inputFields) { | ||
| 51 | const schema = typeRefToJsonSchema(field.type, types); | ||
| 52 | if (field.description) { | ||
| 53 | (schema as Record<string, unknown>).description = field.description; | ||
| 54 | } | ||
| 55 | properties[field.name] = schema; | ||
| 56 | if (isNonNull(field.type)) { | ||
| 57 | required.push(field.name); | ||
| 58 | } | ||
| 59 | } | ||
| 60 | |||
| 61 | const def: Record<string, unknown> = { type: "object", properties }; | ||
| 62 | if (required.length > 0) def.required = required; | ||
| 63 | if (type.description) def.description = type.description; | ||
| 64 | defs[name] = def; | ||
| 65 | } | ||
| 66 | |||
| 67 | if (type.kind === "ENUM" && type.enumValues) { | ||
| 68 | defs[name] = { | ||
| 69 | type: "string", | ||
| 70 | enum: type.enumValues.map((v) => v.name), | ||
| 71 | ...(type.description ? { description: type.description } : {}), | ||
| 72 | }; | ||
| 73 | } | ||
| 74 | } | ||
| 75 | |||
| 76 | return defs; | ||
| 77 | }; | ||
| 78 | |||
| 79 | // --------------------------------------------------------------------------- | ||
| 80 | // Convert a type ref to JSON Schema using $ref for complex types | ||
| 81 | // --------------------------------------------------------------------------- | ||
| 82 | |||
| 83 | const typeRefToJsonSchema = ( | ||
| 84 | ref: IntrospectionTypeRef, | ||
| 85 | types: ReadonlyMap<string, IntrospectionType>, | ||
| 86 | ): Record<string, unknown> => { | ||
| 87 | switch (ref.kind) { | ||
| 88 | case "NON_NULL": | ||
| 89 | return ref.ofType ? typeRefToJsonSchema(ref.ofType, types) : {}; | ||
| 90 | |||
| 91 | case "LIST": | ||
| 92 | return { | ||
| 93 | type: "array", | ||
| 94 | items: ref.ofType ? typeRefToJsonSchema(ref.ofType, types) : {}, | ||
| 95 | }; | ||
| 96 | |||
| 97 | case "SCALAR": | ||
| 98 | return scalarToJsonSchema(ref.name ?? "String"); | ||
| 99 | |||
| 100 | case "ENUM": | ||
| 101 | // Reference the shared definition | ||
| 102 | return ref.name ? { $ref: `#/$defs/${ref.name}` } : { type: "string" }; | ||
| 103 | |||
| 104 | case "INPUT_OBJECT": | ||
| 105 | // Reference the shared definition — no recursive expansion needed | ||
| 106 | return ref.name ? { $ref: `#/$defs/${ref.name}` } : { type: "object" }; | ||
| 107 | |||
| 108 | case "OBJECT": | ||
| 109 | case "INTERFACE": | ||
| 110 | case "UNION": | ||
| 111 | return { type: "object" }; | ||
| 112 | |||
| 113 | default: | ||
| 114 | return {}; | ||
| 115 | } | ||
| 116 | }; | ||
| 117 | |||
| 118 | const scalarToJsonSchema = (name: string): Record<string, unknown> => { | ||
| 119 | switch (name) { | ||
| 120 | case "String": | ||
| 121 | case "ID": | ||
| 122 | return { type: "string" }; | ||
| 123 | case "Int": | ||
| 124 | return { type: "integer" }; | ||
| 125 | case "Float": | ||
| 126 | return { type: "number" }; | ||
| 127 | case "Boolean": | ||
| 128 | return { type: "boolean" }; | ||
| 129 | default: | ||
| 130 | return { type: "string", description: `Custom scalar: ${name}` }; | ||
| 131 | } | ||
| 132 | }; | ||
| 133 | |||
| 134 | // --------------------------------------------------------------------------- | ||
| 135 | // Build input JSON Schema from field arguments | ||
| 136 | // --------------------------------------------------------------------------- | ||
| 137 | |||
| 138 | const buildInputSchema = ( | ||
| 139 | args: readonly IntrospectionInputValue[], | ||
| 140 | types: ReadonlyMap<string, IntrospectionType>, | ||
| 141 | ): Record<string, unknown> | undefined => { | ||
| 142 | if (args.length === 0) return undefined; | ||
| 143 | |||
| 144 | const properties: Record<string, unknown> = {}; | ||
| 145 | const required: string[] = []; | ||
| 146 | |||
| 147 | for (const arg of args) { | ||
| 148 | const schema = typeRefToJsonSchema(arg.type, types); | ||
| 149 | if (arg.description) { | ||
| 150 | (schema as Record<string, unknown>).description = arg.description; | ||
| 151 | } | ||
| 152 | properties[arg.name] = schema; | ||
| 153 | if (isNonNull(arg.type)) { | ||
| 154 | required.push(arg.name); | ||
| 155 | } | ||
| 156 | } | ||
| 157 | |||
| 158 | const inputSchema: Record<string, unknown> = { | ||
| 159 | type: "object", | ||
| 160 | properties, | ||
| 161 | }; | ||
| 162 | if (required.length > 0) inputSchema.required = required; | ||
| 163 | return inputSchema; | ||
| 164 | }; | ||
| 165 | |||
| 166 | /** Format a type ref back to GraphQL type notation (e.g. "[String!]!") */ | ||
| 167 | const formatTypeRef = (ref: IntrospectionTypeRef): string => { | ||
| 168 | switch (ref.kind) { | ||
| 169 | case "NON_NULL": | ||
| 170 | return ref.ofType ? `${formatTypeRef(ref.ofType)}!` : "Unknown!"; | ||
| 171 | case "LIST": | ||
| 172 | return ref.ofType ? `[${formatTypeRef(ref.ofType)}]` : "[Unknown]"; | ||
| 173 | default: | ||
| 174 | return ref.name ?? "Unknown"; | ||
| 175 | } | ||
| 176 | }; | ||
| 177 | |||
| 178 | // --------------------------------------------------------------------------- | ||
| 179 | // Extract fields from schema | ||
| 180 | // --------------------------------------------------------------------------- | ||
| 181 | |||
| 182 | const extractFields = ( | ||
| 183 | _schema: IntrospectionSchema, | ||
| 184 | kind: GraphqlOperationKind, | ||
| 185 | typeName: string | null | undefined, | ||
| 186 | types: ReadonlyMap<string, IntrospectionType>, | ||
| 187 | ): ExtractedField[] => { | ||
| 188 | if (!typeName) return []; | ||
| 189 | |||
| 190 | const type = types.get(typeName); | ||
| 191 | if (!type?.fields) return []; | ||
| 192 | |||
| 193 | return type.fields | ||
| 194 | .filter((f) => !f.name.startsWith("__")) | ||
| 195 | .map((field) => { | ||
| 196 | const args = field.args.map( | ||
| 197 | (arg) => | ||
| 198 | new GraphqlArgument({ | ||
| 199 | name: arg.name, | ||
| 200 | typeName: formatTypeRef(arg.type), | ||
| 201 | required: isNonNull(arg.type), | ||
| 202 | description: arg.description ? Option.some(arg.description) : Option.none(), | ||
| 203 | }), | ||
| 204 | ); | ||
| 205 | |||
| 206 | const inputSchema = buildInputSchema(field.args, types); | ||
| 207 | |||
| 208 | return new ExtractedField({ | ||
| 209 | fieldName: field.name, | ||
| 210 | kind, | ||
| 211 | description: field.description | ||
| 212 | ? Option.some(field.description) | ||
| 213 | : Option.none(), | ||
| 214 | arguments: args, | ||
| 215 | inputSchema: inputSchema ? Option.some(inputSchema) : Option.none(), | ||
| 216 | returnTypeName: unwrapTypeName(field.type), | ||
| 217 | }); | ||
| 218 | }); | ||
| 219 | }; | ||
| 220 | |||
| 221 | // --------------------------------------------------------------------------- | ||
| 222 | // Public API | ||
| 223 | // --------------------------------------------------------------------------- | ||
| 224 | |||
| 225 | export interface ExtractionOutput { | ||
| 226 | readonly result: ExtractionResult; | ||
| 227 | /** Shared JSON Schema definitions for INPUT_OBJECT and ENUM types. | ||
| 228 | * Tool input schemas use `$ref` pointers into these. */ | ||
| 229 | readonly definitions: Record<string, unknown>; | ||
| 230 | } | ||
| 231 | |||
| 232 | export const extract = ( | ||
| 233 | introspection: IntrospectionResult, | ||
| 234 | ): Effect.Effect<ExtractionOutput, GraphqlExtractionError> => | ||
| 235 | Effect.try({ | ||
| 236 | try: () => { | ||
| 237 | const schema = introspection.__schema; | ||
| 238 | const typeMap = new Map<string, IntrospectionType>(); | ||
| 239 | for (const t of schema.types) { | ||
| 240 | typeMap.set(t.name, t); | ||
| 241 | } | ||
| 242 | |||
| 243 | const definitions = buildDefinitions(typeMap); | ||
| 244 | |||
| 245 | const queryFields = extractFields( | ||
| 246 | schema, | ||
| 247 | "query", | ||
| 248 | schema.queryType?.name, | ||
| 249 | typeMap, | ||
| 250 | ); | ||
| 251 | const mutationFields = extractFields( | ||
| 252 | schema, | ||
| 253 | "mutation", | ||
| 254 | schema.mutationType?.name, | ||
| 255 | typeMap, | ||
| 256 | ); | ||
| 257 | |||
| 258 | return { | ||
| 259 | result: new ExtractionResult({ | ||
| 260 | schemaName: Option.none(), | ||
| 261 | fields: [...queryFields, ...mutationFields], | ||
| 262 | }), | ||
| 263 | definitions, | ||
| 264 | }; | ||
| 265 | }, | ||
| 266 | catch: (err) => | ||
| 267 | new GraphqlExtractionError({ | ||
| 268 | message: `Failed to extract GraphQL schema: ${err instanceof Error ? err.message : String(err)}`, | ||
| 269 | }), | ||
| 270 | }); | ||
| @@ -0,0 +1,33 @@ | |||
| 1 | export { introspect, parseIntrospectionJson } from "./introspect"; | ||
| 2 | export { extract, type ExtractionOutput } from "./extract"; | ||
| 3 | export { invoke, makeGraphqlInvoker } from "./invoke"; | ||
| 4 | export { | ||
| 5 | graphqlPlugin, | ||
| 6 | type GraphqlSourceConfig, | ||
| 7 | type GraphqlPluginExtension, | ||
| 8 | } from "./plugin"; | ||
| 9 | export { | ||
| 10 | type GraphqlOperationStore, | ||
| 11 | type SourceMeta, | ||
| 12 | } from "./operation-store"; | ||
| 13 | export { | ||
| 14 | makeKvOperationStore, | ||
| 15 | makeInMemoryOperationStore, | ||
| 16 | } from "./kv-operation-store"; | ||
| 17 | |||
| 18 | export { | ||
| 19 | GraphqlIntrospectionError, | ||
| 20 | GraphqlExtractionError, | ||
| 21 | GraphqlInvocationError, | ||
| 22 | } from "./errors"; | ||
| 23 | |||
| 24 | export { | ||
| 25 | ExtractedField, | ||
| 26 | ExtractionResult, | ||
| 27 | GraphqlArgument, | ||
| 28 | GraphqlOperationKind, | ||
| 29 | InvocationConfig, | ||
| 30 | InvocationResult, | ||
| 31 | OperationBinding, | ||
| 32 | HeaderValue, | ||
| 33 | } from "./types"; | ||
| @@ -0,0 +1,220 @@ | |||
| 1 | import { Effect } from "effect"; | ||
| 2 | import { HttpClient, HttpClientRequest } from "@effect/platform"; | ||
| 3 | |||
| 4 | import { GraphqlIntrospectionError } from "./errors"; | ||
| 5 | |||
| 6 | // --------------------------------------------------------------------------- | ||
| 7 | // Introspection query — standard GraphQL introspection | ||
| 8 | // --------------------------------------------------------------------------- | ||
| 9 | |||
| 10 | const INTROSPECTION_QUERY = ` | ||
| 11 | query IntrospectionQuery { | ||
| 12 | __schema { | ||
| 13 | queryType { name } | ||
| 14 | mutationType { name } | ||
| 15 | types { | ||
| 16 | kind | ||
| 17 | name | ||
| 18 | description | ||
| 19 | fields(includeDeprecated: false) { | ||
| 20 | name | ||
| 21 | description | ||
| 22 | args { | ||
| 23 | name | ||
| 24 | description | ||
| 25 | type { | ||
| 26 | ...TypeRef | ||
| 27 | } | ||
| 28 | defaultValue | ||
| 29 | } | ||
| 30 | type { | ||
| 31 | ...TypeRef | ||
| 32 | } | ||
| 33 | } | ||
| 34 | inputFields { | ||
| 35 | name | ||
| 36 | description | ||
| 37 | type { | ||
| 38 | ...TypeRef | ||
| 39 | } | ||
| 40 | defaultValue | ||
| 41 | } | ||
| 42 | enumValues(includeDeprecated: false) { | ||
| 43 | name | ||
| 44 | description | ||
| 45 | } | ||
| 46 | } | ||
| 47 | } | ||
| 48 | } | ||
| 49 | |||
| 50 | fragment TypeRef on __Type { | ||
| 51 | kind | ||
| 52 | name | ||
| 53 | ofType { | ||
| 54 | kind | ||
| 55 | name | ||
| 56 | ofType { | ||
| 57 | kind | ||
| 58 | name | ||
| 59 | ofType { | ||
| 60 | kind | ||
| 61 | name | ||
| 62 | ofType { | ||
| 63 | kind | ||
| 64 | name | ||
| 65 | ofType { | ||
| 66 | kind | ||
| 67 | name | ||
| 68 | ofType { | ||
| 69 | kind | ||
| 70 | name | ||
| 71 | } | ||
| 72 | } | ||
| 73 | } | ||
| 74 | } | ||
| 75 | } | ||
| 76 | } | ||
| 77 | } | ||
| 78 | `; | ||
| 79 | |||
| 80 | // --------------------------------------------------------------------------- | ||
| 81 | // Introspection result types | ||
| 82 | // --------------------------------------------------------------------------- | ||
| 83 | |||
| 84 | export interface IntrospectionTypeRef { | ||
| 85 | readonly kind: string; | ||
| 86 | readonly name: string | null; | ||
| 87 | readonly ofType: IntrospectionTypeRef | null; | ||
| 88 | } | ||
| 89 | |||
| 90 | export interface IntrospectionInputValue { | ||
| 91 | readonly name: string; | ||
| 92 | readonly description: string | null; | ||
| 93 | readonly type: IntrospectionTypeRef; | ||
| 94 | readonly defaultValue: string | null; | ||
| 95 | } | ||
| 96 | |||
| 97 | export interface IntrospectionField { | ||
| 98 | readonly name: string; | ||
| 99 | readonly description: string | null; | ||
| 100 | readonly args: readonly IntrospectionInputValue[]; | ||
| 101 | readonly type: IntrospectionTypeRef; | ||
| 102 | } | ||
| 103 | |||
| 104 | export interface IntrospectionEnumValue { | ||
| 105 | readonly name: string; | ||
| 106 | readonly description: string | null; | ||
| 107 | } | ||
| 108 | |||
| 109 | export interface IntrospectionType { | ||
| 110 | readonly kind: string; | ||
| 111 | readonly name: string; | ||
| 112 | readonly description: string | null; | ||
| 113 | readonly fields: readonly IntrospectionField[] | null; | ||
| 114 | readonly inputFields: readonly IntrospectionInputValue[] | null; | ||
| 115 | readonly enumValues: readonly IntrospectionEnumValue[] | null; | ||
| 116 | } | ||
| 117 | |||
| 118 | export interface IntrospectionSchema { | ||
| 119 | readonly queryType: { readonly name: string } | null; | ||
| 120 | readonly mutationType: { readonly name: string } | null; | ||
| 121 | readonly types: readonly IntrospectionType[]; | ||
| 122 | } | ||
| 123 | |||
| 124 | export interface IntrospectionResult { | ||
| 125 | readonly __schema: IntrospectionSchema; | ||
| 126 | } | ||
| 127 | |||
| 128 | // --------------------------------------------------------------------------- | ||
| 129 | // Introspect a GraphQL endpoint | ||
| 130 | // --------------------------------------------------------------------------- | ||
| 131 | |||
| 132 | export const introspect = Effect.fn("GraphQL.introspect")(function* ( | ||
| 133 | endpoint: string, | ||
| 134 | headers?: Record<string, string>, | ||
| 135 | ) { | ||
| 136 | const client = yield* HttpClient.HttpClient; | ||
| 137 | |||
| 138 | let request = HttpClientRequest.post(endpoint).pipe( | ||
| 139 | HttpClientRequest.setHeader("Content-Type", "application/json"), | ||
| 140 | HttpClientRequest.bodyUnsafeJson({ | ||
| 141 | query: INTROSPECTION_QUERY, | ||
| 142 | }), | ||
| 143 | ); | ||
| 144 | |||
| 145 | if (headers) { | ||
| 146 | for (const [k, v] of Object.entries(headers)) { | ||
| 147 | request = HttpClientRequest.setHeader(request, k, v); | ||
| 148 | } | ||
| 149 | } | ||
| 150 | |||
| 151 | const response = yield* client.execute(request).pipe( | ||
| 152 | Effect.mapError( | ||
| 153 | (err) => | ||
| 154 | new GraphqlIntrospectionError({ | ||
| 155 | message: `Failed to reach GraphQL endpoint: ${err.message}`, | ||
| 156 | error: err, | ||
| 157 | }), | ||
| 158 | ), | ||
| 159 | ); | ||
| 160 | |||
| 161 | if (response.status !== 200) { | ||
| 162 | const body = yield* response.text.pipe(Effect.catchAll(() => Effect.succeed(""))); | ||
| 163 | return yield* new GraphqlIntrospectionError({ | ||
| 164 | message: `Introspection failed with status ${response.status}: ${body}`, | ||
| 165 | error: undefined, | ||
| 166 | }); | ||
| 167 | } | ||
| 168 | |||
| 169 | const raw = yield* response.json.pipe( | ||
| 170 | Effect.mapError( | ||
| 171 | (err) => | ||
| 172 | new GraphqlIntrospectionError({ | ||
| 173 | message: `Failed to parse introspection response as JSON`, | ||
| 174 | error: err, | ||
| 175 | }), | ||
| 176 | ), | ||
| 177 | ); | ||
| 178 | |||
| 179 | const json = raw as { data?: IntrospectionResult; errors?: unknown[] }; | ||
| 180 | |||
| 181 | if (json.errors && Array.isArray(json.errors) && json.errors.length > 0) { | ||
| 182 | return yield* new GraphqlIntrospectionError({ | ||
| 183 | message: `Introspection returned errors: ${JSON.stringify(json.errors)}`, | ||
| 184 | error: undefined, | ||
| 185 | }); | ||
| 186 | } | ||
| 187 | |||
| 188 | if (!json.data?.__schema) { | ||
| 189 | return yield* new GraphqlIntrospectionError({ | ||
| 190 | message: "Introspection response missing __schema", | ||
| 191 | error: undefined, | ||
| 192 | }); | ||
| 193 | } | ||
| 194 | |||
| 195 | return json.data; | ||
| 196 | }); | ||
| 197 | |||
| 198 | // --------------------------------------------------------------------------- | ||
| 199 | // Parse an introspection result from a JSON string (for offline/text input) | ||
| 200 | // --------------------------------------------------------------------------- | ||
| 201 | |||
| 202 | export const parseIntrospectionJson = ( | ||
| 203 | text: string, | ||
| 204 | ): Effect.Effect<IntrospectionResult, GraphqlIntrospectionError> => | ||
| 205 | Effect.try({ | ||
| 206 | try: () => { | ||
| 207 | const parsed = JSON.parse(text); | ||
| 208 | // Accept both { data: { __schema } } and { __schema } formats | ||
| 209 | const result = parsed.data ?? parsed; | ||
| 210 | if (!result.__schema) { | ||
| 211 | throw new Error("Missing __schema in introspection JSON"); | ||
| 212 | } | ||
| 213 | return result as IntrospectionResult; | ||
| 214 | }, | ||
| 215 | catch: (err) => | ||
| 216 | new GraphqlIntrospectionError({ | ||
| 217 | message: `Failed to parse introspection JSON: ${err instanceof Error ? err.message : String(err)}`, | ||
| 218 | error: err, | ||
| 219 | }), | ||
| 220 | }); | ||
| @@ -0,0 +1,210 @@ | |||
| 1 | import { Effect, Layer, Option } from "effect"; | ||
| 2 | import { HttpClient, HttpClientRequest } from "@effect/platform"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | type ToolId, | ||
| 6 | type ToolInvoker, | ||
| 7 | ToolInvocationResult, | ||
| 8 | ToolInvocationError, | ||
| 9 | type ScopeId, | ||
| 10 | type SecretId, | ||
| 11 | } from "@executor/sdk"; | ||
| 12 | |||
| 13 | import { GraphqlInvocationError } from "./errors"; | ||
| 14 | import type { GraphqlOperationStore } from "./operation-store"; | ||
| 15 | import { | ||
| 16 | type HeaderValue, | ||
| 17 | type OperationBinding, | ||
| 18 | InvocationConfig, | ||
| 19 | InvocationResult, | ||
| 20 | } from "./types"; | ||
| 21 | |||
| 22 | // --------------------------------------------------------------------------- | ||
| 23 | // Header resolution — resolves secret refs at invocation time | ||
| 24 | // --------------------------------------------------------------------------- | ||
| 25 | |||
| 26 | const resolveHeaders = ( | ||
| 27 | headers: Record<string, HeaderValue>, | ||
| 28 | secrets: { readonly resolve: (secretId: SecretId, scopeId: ScopeId) => Effect.Effect<string, unknown> }, | ||
| 29 | scopeId: ScopeId, | ||
| 30 | ): Effect.Effect<Record<string, string>, ToolInvocationError> => | ||
| 31 | Effect.gen(function* () { | ||
| 32 | const resolved: Record<string, string> = {}; | ||
| 33 | for (const [name, value] of Object.entries(headers)) { | ||
| 34 | if (typeof value === "string") { | ||
| 35 | resolved[name] = value; | ||
| 36 | } else { | ||
| 37 | const secret = yield* secrets.resolve(value.secretId as SecretId, scopeId).pipe( | ||
| 38 | Effect.mapError(() => | ||
| 39 | new ToolInvocationError({ | ||
| 40 | toolId: "" as ToolId, | ||
| 41 | message: `Failed to resolve secret "${value.secretId}" for header "${name}"`, | ||
| 42 | cause: undefined, | ||
| 43 | }), | ||
| 44 | ), | ||
| 45 | ); | ||
| 46 | resolved[name] = value.prefix ? `${value.prefix}${secret}` : secret; | ||
| 47 | } | ||
| 48 | } | ||
| 49 | return resolved; | ||
| 50 | }); | ||
| 51 | |||
| 52 | // --------------------------------------------------------------------------- | ||
| 53 | // Response helpers | ||
| 54 | // --------------------------------------------------------------------------- | ||
| 55 | |||
| 56 | const isJsonContentType = (ct: string | null | undefined): boolean => { | ||
| 57 | if (!ct) return false; | ||
| 58 | const normalized = ct.split(";")[0]?.trim().toLowerCase() ?? ""; | ||
| 59 | return ( | ||
| 60 | normalized === "application/json" || | ||
| 61 | normalized.includes("+json") || | ||
| 62 | normalized.includes("json") | ||
| 63 | ); | ||
| 64 | }; | ||
| 65 | |||
| 66 | // --------------------------------------------------------------------------- | ||
| 67 | // Public API — execute a GraphQL operation | ||
| 68 | // --------------------------------------------------------------------------- | ||
| 69 | |||
| 70 | export const invoke = Effect.fn("GraphQL.invoke")(function* ( | ||
| 71 | operation: OperationBinding, | ||
| 72 | args: Record<string, unknown>, | ||
| 73 | config: InvocationConfig, | ||
| 74 | resolvedHeaders?: Record<string, string>, | ||
| 75 | ) { | ||
| 76 | const client = yield* HttpClient.HttpClient; | ||
| 77 | |||
| 78 | // Build the GraphQL request body | ||
| 79 | const variables: Record<string, unknown> = {}; | ||
| 80 | for (const varName of operation.variableNames) { | ||
| 81 | if (args[varName] !== undefined) { | ||
| 82 | variables[varName] = args[varName]; | ||
| 83 | } | ||
| 84 | } | ||
| 85 | |||
| 86 | // Also pick up any variables from a "variables" container | ||
| 87 | if (typeof args.variables === "object" && args.variables !== null) { | ||
| 88 | Object.assign(variables, args.variables); | ||
| 89 | } | ||
| 90 | |||
| 91 | let request = HttpClientRequest.post(config.endpoint).pipe( | ||
| 92 | HttpClientRequest.setHeader("Content-Type", "application/json"), | ||
| 93 | HttpClientRequest.bodyUnsafeJson({ | ||
| 94 | query: operation.operationString, | ||
| 95 | variables: Object.keys(variables).length > 0 ? variables : undefined, | ||
| 96 | }), | ||
| 97 | ); | ||
| 98 | |||
| 99 | // Apply resolved headers | ||
| 100 | if (resolvedHeaders) { | ||
| 101 | for (const [name, value] of Object.entries(resolvedHeaders)) { | ||
| 102 | request = HttpClientRequest.setHeader(request, name, value); | ||
| 103 | } | ||
| 104 | } | ||
| 105 | |||
| 106 | const response = yield* client.execute(request).pipe( | ||
| 107 | Effect.mapError( | ||
| 108 | (err) => | ||
| 109 | new GraphqlInvocationError({ | ||
| 110 | message: `GraphQL request failed: ${err.message}`, | ||
| 111 | statusCode: Option.none(), | ||
| 112 | error: err, | ||
| 113 | }), | ||
| 114 | ), | ||
| 115 | ); | ||
| 116 | |||
| 117 | const status = response.status; | ||
| 118 | const contentType = response.headers["content-type"] ?? null; | ||
| 119 | |||
| 120 | const body: unknown = isJsonContentType(contentType) | ||
| 121 | ? yield* response.json.pipe(Effect.catchAll(() => response.text)) | ||
| 122 | : yield* response.text; | ||
| 123 | |||
| 124 | // GraphQL responses are always 200 with { data, errors } | ||
| 125 | const gqlBody = body as { data?: unknown; errors?: unknown[] } | null; | ||
| 126 | const hasErrors = Array.isArray(gqlBody?.errors) && gqlBody.errors.length > 0; | ||
| 127 | |||
| 128 | return new InvocationResult({ | ||
| 129 | status, | ||
| 130 | data: gqlBody?.data ?? null, | ||
| 131 | errors: hasErrors ? gqlBody!.errors : null, | ||
| 132 | }); | ||
| 133 | }); | ||
| 134 | |||
| 135 | // --------------------------------------------------------------------------- | ||
| 136 | // ToolInvoker — bridges operation store + HTTP client into SDK invoker | ||
| 137 | // --------------------------------------------------------------------------- | ||
| 138 | |||
| 139 | export const makeGraphqlInvoker = (opts: { | ||
| 140 | readonly operationStore: GraphqlOperationStore; | ||
| 141 | readonly httpClientLayer: Layer.Layer<HttpClient.HttpClient>; | ||
| 142 | readonly secrets: { readonly resolve: (secretId: SecretId, scopeId: ScopeId) => Effect.Effect<string, unknown> }; | ||
| 143 | readonly scopeId: ScopeId; | ||
| 144 | }): ToolInvoker => ({ | ||
| 145 | resolveAnnotations: (toolId: ToolId) => | ||
| 146 | Effect.gen(function* () { | ||
| 147 | const entry = yield* opts.operationStore.get(toolId); | ||
| 148 | if (!entry) return undefined; | ||
| 149 | // Mutations require approval, queries don't | ||
| 150 | if (entry.binding.kind === "mutation") { | ||
| 151 | return { | ||
| 152 | requiresApproval: true, | ||
| 153 | approvalDescription: `mutation ${entry.binding.fieldName}`, | ||
| 154 | }; | ||
| 155 | } | ||
| 156 | return {}; | ||
| 157 | }), | ||
| 158 | |||
| 159 | invoke: (toolId: ToolId, args: unknown) => | ||
| 160 | Effect.gen(function* () { | ||
| 161 | const entry = yield* opts.operationStore.get(toolId); | ||
| 162 | if (!entry) { | ||
| 163 | return yield* new ToolInvocationError({ | ||
| 164 | toolId, | ||
| 165 | message: `No GraphQL operation found for tool "${toolId}"`, | ||
| 166 | cause: undefined, | ||
| 167 | }); | ||
| 168 | } | ||
| 169 | |||
| 170 | const { binding, config } = entry; | ||
| 171 | |||
| 172 | // Resolve secret-backed headers | ||
| 173 | const resolvedHeaders = yield* resolveHeaders( | ||
| 174 | config.headers, | ||
| 175 | opts.secrets, | ||
| 176 | opts.scopeId, | ||
| 177 | ); | ||
| 178 | |||
| 179 | const result = yield* invoke( | ||
| 180 | binding, | ||
| 181 | (args ?? {}) as Record<string, unknown>, | ||
| 182 | config, | ||
| 183 | resolvedHeaders, | ||
| 184 | ).pipe(Effect.provide(opts.httpClientLayer)); | ||
| 185 | |||
| 186 | return new ToolInvocationResult({ | ||
| 187 | data: result.data, | ||
| 188 | error: result.errors, | ||
| 189 | status: result.status, | ||
| 190 | }); | ||
| 191 | }).pipe( | ||
| 192 | Effect.catchAll((err) => { | ||
| 193 | if ( | ||
| 194 | typeof err === "object" && | ||
| 195 | err !== null && | ||
| 196 | "_tag" in err && | ||
| 197 | (err as { _tag: string })._tag === "ToolInvocationError" | ||
| 198 | ) { | ||
| 199 | return Effect.fail(err as ToolInvocationError); | ||
| 200 | } | ||
| 201 | return Effect.fail( | ||
| 202 | new ToolInvocationError({ | ||
| 203 | toolId, | ||
| 204 | message: `GraphQL invocation failed: ${err instanceof Error ? err.message : String(err)}`, | ||
| 205 | cause: err, | ||
| 206 | }), | ||
| 207 | ); | ||
| 208 | }), | ||
| 209 | ), | ||
| 210 | }); | ||
| @@ -0,0 +1,99 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // KV-backed GraphqlOperationStore | ||
| 3 | // --------------------------------------------------------------------------- | ||
| 4 | |||
| 5 | import { Effect, Schema } from "effect"; | ||
| 6 | import { scopeKv, makeInMemoryScopedKv, type Kv, type ToolId, type ScopedKv } from "@executor/sdk"; | ||
| 7 | |||
| 8 | import type { GraphqlOperationStore, SourceMeta } from "./operation-store"; | ||
| 9 | import { OperationBinding, InvocationConfig } from "./types"; | ||
| 10 | |||
| 11 | // --------------------------------------------------------------------------- | ||
| 12 | // Stored entry schema | ||
| 13 | // --------------------------------------------------------------------------- | ||
| 14 | |||
| 15 | class StoredEntry extends Schema.Class<StoredEntry>("StoredEntry")({ | ||
| 16 | namespace: Schema.String, | ||
| 17 | binding: OperationBinding, | ||
| 18 | config: InvocationConfig, | ||
| 19 | }) {} | ||
| 20 | |||
| 21 | const encodeEntry = Schema.encodeSync(Schema.parseJson(StoredEntry)); | ||
| 22 | const decodeEntry = Schema.decodeUnknownSync(Schema.parseJson(StoredEntry)); | ||
| 23 | |||
| 24 | // --------------------------------------------------------------------------- | ||
| 25 | // Implementation | ||
| 26 | // --------------------------------------------------------------------------- | ||
| 27 | |||
| 28 | const makeStore = ( | ||
| 29 | bindings: ScopedKv, | ||
| 30 | meta: ScopedKv, | ||
| 31 | ): GraphqlOperationStore => ({ | ||
| 32 | get: (toolId) => | ||
| 33 | Effect.gen(function* () { | ||
| 34 | const raw = yield* bindings.get(toolId); | ||
| 35 | if (!raw) return null; | ||
| 36 | const entry = decodeEntry(raw); | ||
| 37 | return { binding: entry.binding, config: entry.config }; | ||
| 38 | }), | ||
| 39 | |||
| 40 | put: (toolId, namespace, binding, config) => | ||
| 41 | bindings.set( | ||
| 42 | toolId, | ||
| 43 | encodeEntry(new StoredEntry({ namespace, binding, config })), | ||
| 44 | ), | ||
| 45 | |||
| 46 | remove: (toolId) => bindings.delete(toolId).pipe(Effect.asVoid), | ||
| 47 | |||
| 48 | listByNamespace: (namespace) => | ||
| 49 | Effect.gen(function* () { | ||
| 50 | const entries = yield* bindings.list(); | ||
| 51 | const ids: ToolId[] = []; | ||
| 52 | for (const e of entries) { | ||
| 53 | const entry = decodeEntry(e.value); | ||
| 54 | if (entry.namespace === namespace) ids.push(e.key as ToolId); | ||
| 55 | } | ||
| 56 | return ids; | ||
| 57 | }), | ||
| 58 | |||
| 59 | removeByNamespace: (namespace) => | ||
| 60 | Effect.gen(function* () { | ||
| 61 | const entries = yield* bindings.list(); | ||
| 62 | const ids: ToolId[] = []; | ||
| 63 | for (const e of entries) { | ||
| 64 | const entry = decodeEntry(e.value); | ||
| 65 | if (entry.namespace === namespace) { | ||
| 66 | ids.push(e.key as ToolId); | ||
| 67 | yield* bindings.delete(e.key); | ||
| 68 | } | ||
| 69 | } | ||
| 70 | return ids; | ||
| 71 | }), | ||
| 72 | |||
| 73 | putSourceMeta: (m) => meta.set(m.namespace, JSON.stringify(m)), | ||
| 74 | |||
| 75 | removeSourceMeta: (namespace) => | ||
| 76 | meta.delete(namespace).pipe(Effect.asVoid), | ||
| 77 | |||
| 78 | listSourceMeta: () => | ||
| 79 | Effect.gen(function* () { | ||
| 80 | const entries = yield* meta.list(); | ||
| 81 | return entries.map((e) => JSON.parse(e.value) as SourceMeta); | ||
| 82 | }), | ||
| 83 | }); | ||
| 84 | |||
| 85 | // --------------------------------------------------------------------------- | ||
| 86 | // Factory | ||
| 87 | // --------------------------------------------------------------------------- | ||
| 88 | |||
| 89 | export const makeKvOperationStore = ( | ||
| 90 | kv: Kv, | ||
| 91 | namespace: string, | ||
| 92 | ): GraphqlOperationStore => | ||
| 93 | makeStore( | ||
| 94 | scopeKv(kv, `${namespace}.bindings`), | ||
| 95 | scopeKv(kv, `${namespace}.sources`), | ||
| 96 | ); | ||
| 97 | |||
| 98 | export const makeInMemoryOperationStore = (): GraphqlOperationStore => | ||
| 99 | makeStore(makeInMemoryScopedKv(), makeInMemoryScopedKv()); | ||
| @@ -0,0 +1,38 @@ | |||
| 1 | import type { Effect } from "effect"; | ||
| 2 | import type { ToolId } from "@executor/sdk"; | ||
| 3 | |||
| 4 | import type { OperationBinding, InvocationConfig } from "./types"; | ||
| 5 | |||
| 6 | // --------------------------------------------------------------------------- | ||
| 7 | // Operation store — plugin's own storage for invocation data | ||
| 8 | // --------------------------------------------------------------------------- | ||
| 9 | |||
| 10 | export interface SourceMeta { | ||
| 11 | readonly namespace: string; | ||
| 12 | readonly name: string; | ||
| 13 | } | ||
| 14 | |||
| 15 | export interface GraphqlOperationStore { | ||
| 16 | readonly get: ( | ||
| 17 | toolId: ToolId, | ||
| 18 | ) => Effect.Effect<{ binding: OperationBinding; config: InvocationConfig } | null>; | ||
| 19 | |||
| 20 | readonly put: ( | ||
| 21 | toolId: ToolId, | ||
| 22 | namespace: string, | ||
| 23 | binding: OperationBinding, | ||
| 24 | config: InvocationConfig, | ||
| 25 | ) => Effect.Effect<void>; | ||
| 26 | |||
| 27 | readonly remove: (toolId: ToolId) => Effect.Effect<void>; | ||
| 28 | |||
| 29 | readonly listByNamespace: (namespace: string) => Effect.Effect<readonly ToolId[]>; | ||
| 30 | |||
| 31 | readonly removeByNamespace: (namespace: string) => Effect.Effect<readonly ToolId[]>; | ||
| 32 | |||
| 33 | readonly putSourceMeta: (meta: SourceMeta) => Effect.Effect<void>; | ||
| 34 | |||
| 35 | readonly removeSourceMeta: (namespace: string) => Effect.Effect<void>; | ||
| 36 | |||
| 37 | readonly listSourceMeta: () => Effect.Effect<readonly SourceMeta[]>; | ||
| 38 | } | ||
| @@ -0,0 +1,195 @@ | |||
| 1 | import { describe, it, expect } from "@effect/vitest"; | ||
| 2 | import { Effect } from "effect"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | createExecutor, | ||
| 6 | makeTestConfig, | ||
| 7 | } from "@executor/sdk"; | ||
| 8 | import { graphqlPlugin } from "./plugin"; | ||
| 9 | import type { IntrospectionResult } from "./introspect"; | ||
| 10 | |||
| 11 | // --------------------------------------------------------------------------- | ||
| 12 | // Mock introspection response | ||
| 13 | // --------------------------------------------------------------------------- | ||
| 14 | |||
| 15 | const introspectionResult: IntrospectionResult = { | ||
| 16 | __schema: { | ||
| 17 | queryType: { name: "Query" }, | ||
| 18 | mutationType: { name: "Mutation" }, | ||
| 19 | types: [ | ||
| 20 | { | ||
| 21 | kind: "OBJECT", | ||
| 22 | name: "Query", | ||
| 23 | description: null, | ||
| 24 | fields: [ | ||
| 25 | { | ||
| 26 | name: "hello", | ||
| 27 | description: "Say hello", | ||
| 28 | args: [ | ||
| 29 | { | ||
| 30 | name: "name", | ||
| 31 | description: null, | ||
| 32 | type: { kind: "SCALAR", name: "String", ofType: null }, | ||
| 33 | defaultValue: null, | ||
| 34 | }, | ||
| 35 | ], | ||
| 36 | type: { kind: "SCALAR", name: "String", ofType: null }, | ||
| 37 | }, | ||
| 38 | ], | ||
| 39 | inputFields: null, | ||
| 40 | enumValues: null, | ||
| 41 | }, | ||
| 42 | { | ||
| 43 | kind: "OBJECT", | ||
| 44 | name: "Mutation", | ||
| 45 | description: null, | ||
| 46 | fields: [ | ||
| 47 | { | ||
| 48 | name: "setGreeting", | ||
| 49 | description: "Set greeting message", | ||
| 50 | args: [ | ||
| 51 | { | ||
| 52 | name: "message", | ||
| 53 | description: null, | ||
| 54 | type: { | ||
| 55 | kind: "NON_NULL", | ||
| 56 | name: null, | ||
| 57 | ofType: { kind: "SCALAR", name: "String", ofType: null }, | ||
| 58 | }, | ||
| 59 | defaultValue: null, | ||
| 60 | }, | ||
| 61 | ], | ||
| 62 | type: { kind: "SCALAR", name: "String", ofType: null }, | ||
| 63 | }, | ||
| 64 | ], | ||
| 65 | inputFields: null, | ||
| 66 | enumValues: null, | ||
| 67 | }, | ||
| 68 | { kind: "SCALAR", name: "String", description: null, fields: null, inputFields: null, enumValues: null }, | ||
| 69 | ], | ||
| 70 | }, | ||
| 71 | }; | ||
| 72 | |||
| 73 | describe("graphqlPlugin", () => { | ||
| 74 | it("registers tools from introspection JSON", async () => { | ||
| 75 | const executor = await Effect.runPromise( | ||
| 76 | createExecutor( | ||
| 77 | makeTestConfig({ | ||
| 78 | plugins: [graphqlPlugin()], | ||
| 79 | }), | ||
| 80 | ), | ||
| 81 | ); | ||
| 82 | |||
| 83 | const result = await Effect.runPromise( | ||
| 84 | executor.graphql.addSource({ | ||
| 85 | endpoint: "http://localhost:4000/graphql", | ||
| 86 | introspectionJson: JSON.stringify({ data: introspectionResult }), | ||
| 87 | namespace: "test_api", | ||
| 88 | }), | ||
| 89 | ); | ||
| 90 | |||
| 91 | expect(result.toolCount).toBe(2); | ||
| 92 | |||
| 93 | const tools = await Effect.runPromise(executor.tools.list()); | ||
| 94 | // +1 for the runtime "addSource" tool | ||
| 95 | const graphqlTools = tools.filter((t) => t.pluginKey === "graphql"); | ||
| 96 | expect(graphqlTools).toHaveLength(3); | ||
| 97 | |||
| 98 | const queryTool = graphqlTools.find((t) => t.name === "query.hello"); | ||
| 99 | expect(queryTool).toBeDefined(); | ||
| 100 | expect(queryTool!.description).toBe("Say hello"); | ||
| 101 | |||
| 102 | const mutationTool = graphqlTools.find((t) => t.name === "mutation.setGreeting"); | ||
| 103 | expect(mutationTool).toBeDefined(); | ||
| 104 | expect(mutationTool!.description).toBe("Set greeting message"); | ||
| 105 | |||
| 106 | await Effect.runPromise(executor.close()); | ||
| 107 | }); | ||
| 108 | |||
| 109 | it("removes a source and its tools", async () => { | ||
| 110 | const executor = await Effect.runPromise( | ||
| 111 | createExecutor( | ||
| 112 | makeTestConfig({ | ||
| 113 | plugins: [graphqlPlugin()], | ||
| 114 | }), | ||
| 115 | ), | ||
| 116 | ); | ||
| 117 | |||
| 118 | await Effect.runPromise( | ||
| 119 | executor.graphql.addSource({ | ||
| 120 | endpoint: "http://localhost:4000/graphql", | ||
| 121 | introspectionJson: JSON.stringify({ data: introspectionResult }), | ||
| 122 | namespace: "removable", | ||
| 123 | }), | ||
| 124 | ); | ||
| 125 | |||
| 126 | let tools = await Effect.runPromise(executor.tools.list()); | ||
| 127 | expect(tools.filter((t) => t.pluginKey === "graphql")).toHaveLength(3); | ||
| 128 | |||
| 129 | await Effect.runPromise(executor.graphql.removeSource("removable")); | ||
| 130 | |||
| 131 | tools = await Effect.runPromise(executor.tools.list()); | ||
| 132 | // Runtime addSource tool remains | ||
| 133 | expect(tools.filter((t) => t.pluginKey === "graphql")).toHaveLength(1); | ||
| 134 | |||
| 135 | await Effect.runPromise(executor.close()); | ||
| 136 | }); | ||
| 137 | |||
| 138 | it("lists sources", async () => { | ||
| 139 | const executor = await Effect.runPromise( | ||
| 140 | createExecutor( | ||
| 141 | makeTestConfig({ | ||
| 142 | plugins: [graphqlPlugin()], | ||
| 143 | }), | ||
| 144 | ), | ||
| 145 | ); | ||
| 146 | |||
| 147 | await Effect.runPromise( | ||
| 148 | executor.graphql.addSource({ | ||
| 149 | endpoint: "http://localhost:4000/graphql", | ||
| 150 | introspectionJson: JSON.stringify({ data: introspectionResult }), | ||
| 151 | namespace: "my_gql", | ||
| 152 | }), | ||
| 153 | ); | ||
| 154 | |||
| 155 | const sources = await Effect.runPromise(executor.sources.list()); | ||
| 156 | // 1 user source + 1 runtime source (executor.graphql) | ||
| 157 | const gqlSources = sources.filter((s) => s.kind === "graphql"); | ||
| 158 | expect(gqlSources).toHaveLength(2); | ||
| 159 | const userSource = gqlSources.find((s) => s.id === "my_gql"); | ||
| 160 | expect(userSource).toBeDefined(); | ||
| 161 | expect(userSource!.canRemove).toBe(true); | ||
| 162 | |||
| 163 | await Effect.runPromise(executor.close()); | ||
| 164 | }); | ||
| 165 | |||
| 166 | it("mutations require approval", async () => { | ||
| 167 | const executor = await Effect.runPromise( | ||
| 168 | createExecutor( | ||
| 169 | makeTestConfig({ | ||
| 170 | plugins: [graphqlPlugin()], | ||
| 171 | }), | ||
| 172 | ), | ||
| 173 | ); | ||
| 174 | |||
| 175 | await Effect.runPromise( | ||
| 176 | executor.graphql.addSource({ | ||
| 177 | endpoint: "http://localhost:4000/graphql", | ||
| 178 | introspectionJson: JSON.stringify({ data: introspectionResult }), | ||
| 179 | namespace: "approval_test", | ||
| 180 | }), | ||
| 181 | ); | ||
| 182 | |||
| 183 | const tools = await Effect.runPromise(executor.tools.list()); | ||
| 184 | const mutationTool = tools.find((t) => t.name === "mutation.setGreeting"); | ||
| 185 | expect(mutationTool).toBeDefined(); | ||
| 186 | |||
| 187 | // Verify the mutation requires approval via annotations | ||
| 188 | const schema = await Effect.runPromise( | ||
| 189 | executor.tools.schema(mutationTool!.id), | ||
| 190 | ); | ||
| 191 | expect(schema).toBeDefined(); | ||
| 192 | |||
| 193 | await Effect.runPromise(executor.close()); | ||
| 194 | }); | ||
| 195 | }); | ||
| @@ -0,0 +1,392 @@ | |||
| 1 | import { Effect, Option, Schema } from "effect"; | ||
| 2 | import { FetchHttpClient, HttpClient } from "@effect/platform"; | ||
| 3 | import type { Layer } from "effect"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | Source, | ||
| 7 | definePlugin, | ||
| 8 | registerRuntimeTools, | ||
| 9 | runtimeTool, | ||
| 10 | type ExecutorPlugin, | ||
| 11 | type PluginContext, | ||
| 12 | ToolId, | ||
| 13 | type SecretId, | ||
| 14 | type ToolRegistration, | ||
| 15 | } from "@executor/sdk"; | ||
| 16 | |||
| 17 | import { introspect, parseIntrospectionJson, type IntrospectionResult, type IntrospectionType, type IntrospectionField } from "./introspect"; | ||
| 18 | import { extract } from "./extract"; | ||
| 19 | import { makeGraphqlInvoker } from "./invoke"; | ||
| 20 | import type { GraphqlOperationStore } from "./operation-store"; | ||
| 21 | import { makeInMemoryOperationStore } from "./kv-operation-store"; | ||
| 22 | import { | ||
| 23 | ExtractedField, | ||
| 24 | HeaderValue as HeaderValueSchema, | ||
| 25 | InvocationConfig, | ||
| 26 | OperationBinding, | ||
| 27 | type HeaderValue as HeaderValueValue, | ||
| 28 | type GraphqlOperationKind, | ||
| 29 | } from "./types"; | ||
| 30 | |||
| 31 | // --------------------------------------------------------------------------- | ||
| 32 | // Plugin config | ||
| 33 | // --------------------------------------------------------------------------- | ||
| 34 | |||
| 35 | export type HeaderValue = HeaderValueValue; | ||
| 36 | |||
| 37 | export interface GraphqlSourceConfig { | ||
| 38 | /** The GraphQL endpoint URL */ | ||
| 39 | readonly endpoint: string; | ||
| 40 | /** Optional: introspection JSON text (if endpoint doesn't support introspection) */ | ||
| 41 | readonly introspectionJson?: string; | ||
| 42 | /** Namespace for the tools (derived from endpoint if not provided) */ | ||
| 43 | readonly namespace?: string; | ||
| 44 | /** Headers applied to every request. Values can reference secrets. */ | ||
| 45 | readonly headers?: Record<string, HeaderValue>; | ||
| 46 | } | ||
| 47 | |||
| 48 | // --------------------------------------------------------------------------- | ||
| 49 | // Plugin extension | ||
| 50 | // --------------------------------------------------------------------------- | ||
| 51 | |||
| 52 | export interface GraphqlPluginExtension { | ||
| 53 | /** Add a GraphQL endpoint and register its operations as tools */ | ||
| 54 | readonly addSource: ( | ||
| 55 | config: GraphqlSourceConfig, | ||
| 56 | ) => Effect.Effect<{ readonly toolCount: number }, Error>; | ||
| 57 | |||
| 58 | /** Remove all tools from a previously added GraphQL source by namespace */ | ||
| 59 | readonly removeSource: (namespace: string) => Effect.Effect<void>; | ||
| 60 | } | ||
| 61 | |||
| 62 | // --------------------------------------------------------------------------- | ||
| 63 | // Helpers | ||
| 64 | // --------------------------------------------------------------------------- | ||
| 65 | |||
| 66 | const AddSourceInputSchema = Schema.Struct({ | ||
| 67 | endpoint: Schema.String, | ||
| 68 | introspectionJson: Schema.optional(Schema.String), | ||
| 69 | namespace: Schema.optional(Schema.String), | ||
| 70 | headers: Schema.optional( | ||
| 71 | Schema.Record({ key: Schema.String, value: HeaderValueSchema }), | ||
| 72 | ), | ||
| 73 | }); | ||
| 74 | type AddSourceInput = typeof AddSourceInputSchema.Type; | ||
| 75 | |||
| 76 | const AddSourceOutputSchema = Schema.Struct({ | ||
| 77 | sourceId: Schema.String, | ||
| 78 | toolCount: Schema.Number, | ||
| 79 | }); | ||
| 80 | |||
| 81 | /** Derive a namespace from an endpoint URL */ | ||
| 82 | const namespaceFromEndpoint = (endpoint: string): string => { | ||
| 83 | try { | ||
| 84 | const url = new URL(endpoint); | ||
| 85 | return url.hostname.replace(/[^a-z0-9]+/gi, "_").toLowerCase(); | ||
| 86 | } catch { | ||
| 87 | return "graphql"; | ||
| 88 | } | ||
| 89 | }; | ||
| 90 | |||
| 91 | const buildOperationStringForField = ( | ||
| 92 | kind: GraphqlOperationKind, | ||
| 93 | field: IntrospectionField, | ||
| 94 | types: ReadonlyMap<string, IntrospectionType>, | ||
| 95 | ): string => { | ||
| 96 | const opType = kind === "query" ? "query" : "mutation"; | ||
| 97 | |||
| 98 | const varDefs = field.args.map((arg) => { | ||
| 99 | const typeName = formatTypeRef(arg.type); | ||
| 100 | return `$${arg.name}: ${typeName}`; | ||
| 101 | }); | ||
| 102 | |||
| 103 | const argPasses = field.args.map((arg) => `${arg.name}: $${arg.name}`); | ||
| 104 | const selectionSet = buildSelectionSet(field.type, types, 0, new Set()); | ||
| 105 | |||
| 106 | const varDefsStr = varDefs.length > 0 ? `(${varDefs.join(", ")})` : ""; | ||
| 107 | const argPassStr = argPasses.length > 0 ? `(${argPasses.join(", ")})` : ""; | ||
| 108 | |||
| 109 | return `${opType}${varDefsStr} { ${field.name}${argPassStr}${selectionSet ? ` ${selectionSet}` : ""} }`; | ||
| 110 | }; | ||
| 111 | |||
| 112 | import type { IntrospectionTypeRef } from "./introspect"; | ||
| 113 | |||
| 114 | const formatTypeRef = (ref: IntrospectionTypeRef): string => { | ||
| 115 | switch (ref.kind) { | ||
| 116 | case "NON_NULL": | ||
| 117 | return ref.ofType ? `${formatTypeRef(ref.ofType)}!` : "Unknown!"; | ||
| 118 | case "LIST": | ||
| 119 | return ref.ofType ? `[${formatTypeRef(ref.ofType)}]` : "[Unknown]"; | ||
| 120 | default: | ||
| 121 | return ref.name ?? "Unknown"; | ||
| 122 | } | ||
| 123 | }; | ||
| 124 | |||
| 125 | const unwrapTypeName = (ref: IntrospectionTypeRef): string => { | ||
| 126 | if (ref.name) return ref.name; | ||
| 127 | if (ref.ofType) return unwrapTypeName(ref.ofType); | ||
| 128 | return "Unknown"; | ||
| 129 | }; | ||
| 130 | |||
| 131 | const buildSelectionSet = ( | ||
| 132 | ref: IntrospectionTypeRef, | ||
| 133 | types: ReadonlyMap<string, IntrospectionType>, | ||
| 134 | depth: number, | ||
| 135 | seen: Set<string>, | ||
| 136 | ): string => { | ||
| 137 | if (depth > 2) return ""; | ||
| 138 | |||
| 139 | const leafName = unwrapTypeName(ref); | ||
| 140 | if (seen.has(leafName)) return ""; | ||
| 141 | |||
| 142 | const objectType = types.get(leafName); | ||
| 143 | if (!objectType?.fields) return ""; | ||
| 144 | |||
| 145 | const kind = objectType.kind; | ||
| 146 | if (kind === "SCALAR" || kind === "ENUM") return ""; | ||
| 147 | |||
| 148 | seen.add(leafName); | ||
| 149 | |||
| 150 | const subFields = objectType.fields | ||
| 151 | .filter((f) => !f.name.startsWith("__")) | ||
| 152 | .slice(0, 12) | ||
| 153 | .map((f) => { | ||
| 154 | const sub = buildSelectionSet(f.type, types, depth + 1, seen); | ||
| 155 | return sub ? `${f.name} ${sub}` : f.name; | ||
| 156 | }); | ||
| 157 | |||
| 158 | seen.delete(leafName); | ||
| 159 | |||
| 160 | return subFields.length > 0 ? `{ ${subFields.join(" ")} }` : ""; | ||
| 161 | }; | ||
| 162 | |||
| 163 | const toRegistration = ( | ||
| 164 | field: ExtractedField, | ||
| 165 | namespace: string, | ||
| 166 | ): ToolRegistration => { | ||
| 167 | const prefix = field.kind === "mutation" ? "mutation" : "query"; | ||
| 168 | const toolPath = `${prefix}.${field.fieldName}`; | ||
| 169 | const description = Option.getOrElse(field.description, () => | ||
| 170 | `GraphQL ${field.kind}: ${field.fieldName} -> ${field.returnTypeName}`, | ||
| 171 | ); | ||
| 172 | |||
| 173 | return { | ||
| 174 | id: ToolId.make(`${namespace}.${toolPath}`), | ||
| 175 | pluginKey: "graphql", | ||
| 176 | sourceId: namespace, | ||
| 177 | name: toolPath, | ||
| 178 | description, | ||
| 179 | inputSchema: Option.getOrUndefined(field.inputSchema), | ||
| 180 | outputSchema: undefined, | ||
| 181 | }; | ||
| 182 | }; | ||
| 183 | |||
| 184 | // --------------------------------------------------------------------------- | ||
| 185 | // Plugin factory | ||
| 186 | // --------------------------------------------------------------------------- | ||
| 187 | |||
| 188 | export const graphqlPlugin = (options?: { | ||
| 189 | readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>; | ||
| 190 | readonly operationStore?: GraphqlOperationStore; | ||
| 191 | }): ExecutorPlugin<"graphql", GraphqlPluginExtension> => { | ||
| 192 | const httpClientLayer = options?.httpClientLayer ?? FetchHttpClient.layer; | ||
| 193 | const operationStore = | ||
| 194 | options?.operationStore ?? makeInMemoryOperationStore(); | ||
| 195 | |||
| 196 | return definePlugin({ | ||
| 197 | key: "graphql", | ||
| 198 | init: (ctx: PluginContext) => | ||
| 199 | Effect.gen(function* () { | ||
| 200 | yield* ctx.tools.registerInvoker( | ||
| 201 | "graphql", | ||
| 202 | makeGraphqlInvoker({ | ||
| 203 | operationStore, | ||
| 204 | httpClientLayer, | ||
| 205 | secrets: ctx.secrets, | ||
| 206 | scopeId: ctx.scope.id, | ||
| 207 | }), | ||
| 208 | ); | ||
| 209 | |||
| 210 | yield* ctx.sources.addManager({ | ||
| 211 | kind: "graphql", | ||
| 212 | |||
| 213 | list: () => | ||
| 214 | operationStore.listSourceMeta().pipe( | ||
| 215 | Effect.map((metas) => | ||
| 216 | metas.map( | ||
| 217 | (meta) => | ||
| 218 | new Source({ | ||
| 219 | id: meta.namespace, | ||
| 220 | name: meta.name, | ||
| 221 | kind: "graphql", | ||
| 222 | runtime: false, | ||
| 223 | canRemove: true, | ||
| 224 | canRefresh: false, | ||
| 225 | }), | ||
| 226 | ), | ||
| 227 | ), | ||
| 228 | ), | ||
| 229 | |||
| 230 | remove: (sourceId: string) => | ||
| 231 | Effect.gen(function* () { | ||
| 232 | yield* operationStore.removeByNamespace(sourceId); | ||
| 233 | yield* operationStore.removeSourceMeta(sourceId); | ||
| 234 | yield* ctx.tools.unregisterBySource(sourceId); | ||
| 235 | }), | ||
| 236 | }); | ||
| 237 | |||
| 238 | const addSourceInternal = (config: GraphqlSourceConfig) => | ||
| 239 | Effect.gen(function* () { | ||
| 240 | // Get introspection result — either by querying the endpoint or parsing provided JSON | ||
| 241 | let introspectionResult: IntrospectionResult; | ||
| 242 | if (config.introspectionJson) { | ||
| 243 | introspectionResult = yield* parseIntrospectionJson(config.introspectionJson); | ||
| 244 | } else { | ||
| 245 | // Resolve all headers (including secret refs) for introspection | ||
| 246 | const resolvedHeaders: Record<string, string> = {}; | ||
| 247 | if (config.headers) { | ||
| 248 | for (const [name, value] of Object.entries(config.headers)) { | ||
| 249 | if (typeof value === "string") { | ||
| 250 | resolvedHeaders[name] = value; | ||
| 251 | } else { | ||
| 252 | const secret = yield* ctx.secrets | ||
| 253 | .resolve(value.secretId as SecretId, ctx.scope.id) | ||
| 254 | .pipe(Effect.catchAll(() => Effect.succeed(""))); | ||
| 255 | if (secret) { | ||
| 256 | resolvedHeaders[name] = value.prefix | ||
| 257 | ? `${value.prefix}${secret}` | ||
| 258 | : secret; | ||
| 259 | } | ||
| 260 | } | ||
| 261 | } | ||
| 262 | } | ||
| 263 | |||
| 264 | introspectionResult = yield* introspect( | ||
| 265 | config.endpoint, | ||
| 266 | Object.keys(resolvedHeaders).length > 0 ? resolvedHeaders : undefined, | ||
| 267 | ).pipe(Effect.provide(httpClientLayer)); | ||
| 268 | } | ||
| 269 | |||
| 270 | const { result, definitions } = yield* extract(introspectionResult); | ||
| 271 | const namespace = config.namespace ?? namespaceFromEndpoint(config.endpoint); | ||
| 272 | |||
| 273 | // Register shared JSON Schema definitions ($ref targets) | ||
| 274 | if (Object.keys(definitions).length > 0) { | ||
| 275 | yield* ctx.tools.registerDefinitions(definitions); | ||
| 276 | } | ||
| 277 | |||
| 278 | const invocationConfig = new InvocationConfig({ | ||
| 279 | endpoint: config.endpoint, | ||
| 280 | headers: config.headers ?? {}, | ||
| 281 | }); | ||
| 282 | |||
| 283 | // Build type map for operation string generation | ||
| 284 | const typeMap = new Map<string, IntrospectionType>(); | ||
| 285 | for (const t of introspectionResult.__schema.types) { | ||
| 286 | typeMap.set(t.name, t); | ||
| 287 | } | ||
| 288 | |||
| 289 | // Build field map for operation strings | ||
| 290 | const fieldMap = new Map<string, { kind: GraphqlOperationKind; field: IntrospectionField }>(); | ||
| 291 | const schema = introspectionResult.__schema; | ||
| 292 | |||
| 293 | for (const rootKind of ["query", "mutation"] as const) { | ||
| 294 | const typeName = rootKind === "query" | ||
| 295 | ? schema.queryType?.name | ||
| 296 | : schema.mutationType?.name; | ||
| 297 | if (!typeName) continue; | ||
| 298 | const rootType = typeMap.get(typeName); | ||
| 299 | if (!rootType?.fields) continue; | ||
| 300 | for (const f of rootType.fields) { | ||
| 301 | if (!f.name.startsWith("__")) { | ||
| 302 | fieldMap.set(`${rootKind}.${f.name}`, { kind: rootKind, field: f }); | ||
| 303 | } | ||
| 304 | } | ||
| 305 | } | ||
| 306 | |||
| 307 | const registrations: ToolRegistration[] = []; | ||
| 308 | |||
| 309 | yield* Effect.forEach( | ||
| 310 | result.fields, | ||
| 311 | (extractedField) => { | ||
| 312 | const reg = toRegistration(extractedField, namespace); | ||
| 313 | registrations.push(reg); | ||
| 314 | |||
| 315 | const key = `${extractedField.kind}.${extractedField.fieldName}`; | ||
| 316 | const entry = fieldMap.get(key); | ||
| 317 | |||
| 318 | const operationString = entry | ||
| 319 | ? buildOperationStringForField(entry.kind, entry.field, typeMap) | ||
| 320 | : `${extractedField.kind} { ${extractedField.fieldName} }`; | ||
| 321 | |||
| 322 | const binding = new OperationBinding({ | ||
| 323 | kind: extractedField.kind, | ||
| 324 | fieldName: extractedField.fieldName, | ||
| 325 | operationString, | ||
| 326 | variableNames: extractedField.arguments.map((a) => a.name), | ||
| 327 | }); | ||
| 328 | |||
| 329 | return operationStore.put(reg.id, namespace, binding, invocationConfig); | ||
| 330 | }, | ||
| 331 | { discard: true }, | ||
| 332 | ); | ||
| 333 | |||
| 334 | yield* ctx.tools.register(registrations); | ||
| 335 | |||
| 336 | yield* operationStore.putSourceMeta({ | ||
| 337 | namespace, | ||
| 338 | name: namespace, | ||
| 339 | }); | ||
| 340 | |||
| 341 | return { sourceId: namespace, toolCount: registrations.length }; | ||
| 342 | }); | ||
| 343 | |||
| 344 | const runtimeTools = yield* registerRuntimeTools({ | ||
| 345 | registry: ctx.tools, | ||
| 346 | sources: ctx.sources, | ||
| 347 | pluginKey: "graphql", | ||
| 348 | source: { | ||
| 349 | id: "executor.graphql", | ||
| 350 | name: "GraphQL", | ||
| 351 | }, | ||
| 352 | tools: [ | ||
| 353 | runtimeTool({ | ||
| 354 | id: "executor.graphql.addSource", | ||
| 355 | name: "addSource", | ||
| 356 | description: | ||
| 357 | "Add a GraphQL endpoint and register its operations as tools", | ||
| 358 | inputSchema: AddSourceInputSchema, | ||
| 359 | outputSchema: AddSourceOutputSchema, | ||
| 360 | handler: (input: AddSourceInput) => addSourceInternal(input), | ||
| 361 | }), | ||
| 362 | ], | ||
| 363 | }); | ||
| 364 | |||
| 365 | return { | ||
| 366 | extension: { | ||
| 367 | addSource: (config: GraphqlSourceConfig) => | ||
| 368 | addSourceInternal(config).pipe( | ||
| 369 | Effect.map(({ toolCount }) => ({ toolCount })), | ||
| 370 | Effect.catchAll((err) => | ||
| 371 | Effect.fail( | ||
| 372 | err instanceof Error ? err : new Error(String(err)), | ||
| 373 | ), | ||
| 374 | ), | ||
| 375 | ), | ||
| 376 | |||
| 377 | removeSource: (namespace: string) => | ||
| 378 | Effect.gen(function* () { | ||
| 379 | const toolIds = | ||
| 380 | yield* operationStore.removeByNamespace(namespace); | ||
| 381 | if (toolIds.length > 0) { | ||
| 382 | yield* ctx.tools.unregister(toolIds); | ||
| 383 | } | ||
| 384 | yield* operationStore.removeSourceMeta(namespace); | ||
| 385 | }), | ||
| 386 | }, | ||
| 387 | |||
| 388 | close: () => runtimeTools.close(), | ||
| 389 | }; | ||
| 390 | }), | ||
| 391 | }); | ||
| 392 | }; | ||
| @@ -0,0 +1,92 @@ | |||
| 1 | import { Schema } from "effect"; | ||
| 2 | |||
| 3 | // --------------------------------------------------------------------------- | ||
| 4 | // GraphQL operation kind | ||
| 5 | // --------------------------------------------------------------------------- | ||
| 6 | |||
| 7 | export const GraphqlOperationKind = Schema.Literal("query", "mutation"); | ||
| 8 | export type GraphqlOperationKind = typeof GraphqlOperationKind.Type; | ||
| 9 | |||
| 10 | // --------------------------------------------------------------------------- | ||
| 11 | // Extracted field (becomes a tool) | ||
| 12 | // --------------------------------------------------------------------------- | ||
| 13 | |||
| 14 | export class GraphqlArgument extends Schema.Class<GraphqlArgument>( | ||
| 15 | "GraphqlArgument", | ||
| 16 | )({ | ||
| 17 | name: Schema.String, | ||
| 18 | typeName: Schema.String, | ||
| 19 | required: Schema.Boolean, | ||
| 20 | description: Schema.optionalWith(Schema.String, { as: "Option" }), | ||
| 21 | }) {} | ||
| 22 | |||
| 23 | export class ExtractedField extends Schema.Class<ExtractedField>( | ||
| 24 | "ExtractedField", | ||
| 25 | )({ | ||
| 26 | /** e.g. "user", "createUser" */ | ||
| 27 | fieldName: Schema.String, | ||
| 28 | /** "query" or "mutation" */ | ||
| 29 | kind: GraphqlOperationKind, | ||
| 30 | description: Schema.optionalWith(Schema.String, { as: "Option" }), | ||
| 31 | arguments: Schema.Array(GraphqlArgument), | ||
| 32 | /** JSON Schema for the input (built from arguments) */ | ||
| 33 | inputSchema: Schema.optionalWith(Schema.Unknown, { as: "Option" }), | ||
| 34 | /** The return type name for documentation */ | ||
| 35 | returnTypeName: Schema.String, | ||
| 36 | }) {} | ||
| 37 | |||
| 38 | export class ExtractionResult extends Schema.Class<ExtractionResult>( | ||
| 39 | "ExtractionResult", | ||
| 40 | )({ | ||
| 41 | /** Schema name from introspection */ | ||
| 42 | schemaName: Schema.optionalWith(Schema.String, { as: "Option" }), | ||
| 43 | fields: Schema.Array(ExtractedField), | ||
| 44 | }) {} | ||
| 45 | |||
| 46 | // --------------------------------------------------------------------------- | ||
| 47 | // Operation binding — minimal data needed to invoke | ||
| 48 | // --------------------------------------------------------------------------- | ||
| 49 | |||
| 50 | export class OperationBinding extends Schema.Class<OperationBinding>( | ||
| 51 | "OperationBinding", | ||
| 52 | )({ | ||
| 53 | kind: GraphqlOperationKind, | ||
| 54 | fieldName: Schema.String, | ||
| 55 | /** The full GraphQL query/mutation string */ | ||
| 56 | operationString: Schema.String, | ||
| 57 | /** Ordered variable names for mapping */ | ||
| 58 | variableNames: Schema.Array(Schema.String), | ||
| 59 | }) {} | ||
| 60 | |||
| 61 | // --------------------------------------------------------------------------- | ||
| 62 | // Invocation | ||
| 63 | // --------------------------------------------------------------------------- | ||
| 64 | |||
| 65 | export const HeaderValue = Schema.Union( | ||
| 66 | Schema.String, | ||
| 67 | Schema.Struct({ | ||
| 68 | secretId: Schema.String, | ||
| 69 | prefix: Schema.optional(Schema.String), | ||
| 70 | }), | ||
| 71 | ); | ||
| 72 | export type HeaderValue = typeof HeaderValue.Type; | ||
| 73 | |||
| 74 | export class InvocationConfig extends Schema.Class<InvocationConfig>( | ||
| 75 | "InvocationConfig", | ||
| 76 | )({ | ||
| 77 | /** The GraphQL endpoint URL */ | ||
| 78 | endpoint: Schema.String, | ||
| 79 | /** Headers applied to every request. Values can reference secrets. */ | ||
| 80 | headers: Schema.optionalWith( | ||
| 81 | Schema.Record({ key: Schema.String, value: HeaderValue }), | ||
| 82 | { default: () => ({}) }, | ||
| 83 | ), | ||
| 84 | }) {} | ||
| 85 | |||
| 86 | export class InvocationResult extends Schema.Class<InvocationResult>( | ||
| 87 | "InvocationResult", | ||
| 88 | )({ | ||
| 89 | status: Schema.Number, | ||
| 90 | data: Schema.NullOr(Schema.Unknown), | ||
| 91 | errors: Schema.NullOr(Schema.Unknown), | ||
| 92 | }) {} | ||
| @@ -0,0 +1,27 @@ | |||
| 1 | { | ||
| 2 | "compilerOptions": { | ||
| 3 | "target": "ES2022", | ||
| 4 | "module": "ESNext", | ||
| 5 | "moduleResolution": "Bundler", | ||
| 6 | "strict": true, | ||
| 7 | "skipLibCheck": true, | ||
| 8 | "noEmit": true, | ||
| 9 | "lib": ["ES2022", "DOM"], | ||
| 10 | "types": ["bun-types", "node"], | ||
| 11 | "noUnusedLocals": true, | ||
| 12 | "noImplicitOverride": true, | ||
| 13 | "jsx": "react-jsx", | ||
| 14 | "plugins": [ | ||
| 15 | { | ||
| 16 | "name": "@effect/language-service", | ||
| 17 | "diagnostics": { | ||
| 18 | "preferSchemaOverJson": "off" | ||
| 19 | } | ||
| 20 | } | ||
| 21 | ] | ||
| 22 | }, | ||
| 23 | "include": [ | ||
| 24 | "src/**/*.ts", | ||
| 25 | "src/**/*.tsx" | ||
| 26 | ] | ||
| 27 | } | ||
| @@ -153,7 +153,7 @@ describe("Real specs: Cloudflare API", () => { · 152 unmodified lines | |||
| 153 | const output = schema.outputSchema as Record<string, unknown>; | 153 | const output = schema.outputSchema as Record<string, unknown>; |
| 154 | 154 | ||
| 155 | expect(output["$ref"]).toBeTypeOf("string"); | 155 | expect(output["$ref"]).toBeTypeOf("string"); |
| 156 | expect((output["$ref"] as string).startsWith("#/components/schemas/")).toBe(true); | 156 | expect((output["$ref"] as string).startsWith("#/$defs/")).toBe(true); |
| 157 | 157 | ||
| 158 | expect(output["$defs"]).toBeDefined(); | 158 | expect(output["$defs"]).toBeDefined(); |
| 159 | const defs = output["$defs"] as Record<string, unknown>; | 159 | const defs = output["$defs"] as Record<string, unknown>; |