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 | 16 | "@executor/plugin-keychain": "workspace:*", |
| 17 | 17 | "@executor/plugin-onepassword": "workspace:*", |
| 18 | 18 | "@executor/plugin-mcp": "workspace:*", |
| 19 | + "@executor/plugin-graphql": "workspace:*", | |
| 19 | 20 | "@executor/plugin-openapi": "workspace:*", |
| 20 | 21 | "@executor/sdk": "workspace:*", |
| 21 | 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 | 11 | import { McpGroup } from "@executor/plugin-mcp/api"; |
| 12 | 12 | import { GoogleDiscoveryGroup } from "@executor/plugin-google-discovery/api"; |
| 13 | 13 | import { OnePasswordGroup } from "@executor/plugin-onepassword/api"; |
| 14 | +import { GraphqlGroup } from "@executor/plugin-graphql/api"; | |
| 14 | 15 | import { ToolsHandlers } from "./handlers/tools"; |
| 15 | 16 | import { SourcesHandlers } from "./handlers/sources"; |
| 16 | 17 | import { SecretsHandlers } from "./handlers/secrets"; |
| @@ -18,6 +19,7 @@ import { OpenApiHandlersLive } from "./handlers/openapi"; · 1 unmodified line | ||
| 18 | 19 | import { McpSourceHandlersLive } from "./handlers/mcp-source"; |
| 19 | 20 | import { GoogleDiscoveryHandlersLive } from "./handlers/google-discovery"; |
| 20 | 21 | import { OnePasswordHandlersLive } from "./handlers/onepassword"; |
| 22 | +import { GraphqlHandlersLive } from "./handlers/graphql"; | |
| 21 | 23 | import { ExecutorService, ExecutorServiceLayer, getExecutor, type ServerExecutor } from "./services/executor"; |
| 22 | 24 | import { createMcpRequestHandler, type McpRequestHandler } from "./mcp"; |
| 23 | 25 | |
| @@ -28,7 +30,8 @@ import { createMcpRequestHandler, type McpRequestHandler } from "./mcp"; · 4 unmodified lines | ||
| 28 | 30 | const ExecutorApiWithPlugins = addGroup(OpenApiGroup) |
| 29 | 31 | .add(McpGroup) |
| 30 | 32 | .add(GoogleDiscoveryGroup) |
| 31 | − .add(OnePasswordGroup); | |
| 33 | + .add(OnePasswordGroup) | |
| 34 | + .add(GraphqlGroup); | |
| 32 | 35 | |
| 33 | 36 | // --------------------------------------------------------------------------- |
| 34 | 37 | // API Layer |
| @@ -43,6 +46,7 @@ const ApiBase = HttpApiBuilder.api(ExecutorApiWithPlugins).pipe( · 8 unmodified lines | ||
| 43 | 46 | McpSourceHandlersLive, |
| 44 | 47 | GoogleDiscoveryHandlersLive, |
| 45 | 48 | OnePasswordHandlersLive, |
| 49 | + GraphqlHandlersLive, | |
| 46 | 50 | ]), |
| 47 | 51 | ); |
| 48 | 52 | |
| @@ -12,6 +12,11 @@ import { · 11 unmodified lines | ||
| 12 | 12 | makeKvBindingStore as makeKvGoogleDiscoveryBindingStore, |
| 13 | 13 | type GoogleDiscoveryPluginExtension, |
| 14 | 14 | } from "@executor/plugin-google-discovery"; |
| 15 | +import { | |
| 16 | + graphqlPlugin, | |
| 17 | + makeKvOperationStore as makeKvGraphqlOperationStore, | |
| 18 | + type GraphqlPluginExtension, | |
| 19 | +} from "@executor/plugin-graphql"; | |
| 15 | 20 | import { keychainPlugin } from "@executor/plugin-keychain"; |
| 16 | 21 | import { fileSecretsPlugin } from "@executor/plugin-file-secrets"; |
| 17 | 22 | import { onepasswordPlugin, type OnePasswordExtension } from "@executor/plugin-onepassword"; |
| @@ -22,6 +27,7 @@ type ServerPlugins = readonly [ · 4 unmodified lines | ||
| 22 | 27 | ExecutorPlugin<"openapi", OpenApiPluginExtension>, |
| 23 | 28 | ExecutorPlugin<"mcp", McpPluginExtension>, |
| 24 | 29 | ExecutorPlugin<"googleDiscovery", GoogleDiscoveryPluginExtension>, |
| 30 | + ExecutorPlugin<"graphql", GraphqlPluginExtension>, | |
| 25 | 31 | ReturnType<typeof keychainPlugin>, |
| 26 | 32 | ReturnType<typeof fileSecretsPlugin>, |
| 27 | 33 | ExecutorPlugin<"onepassword", OnePasswordExtension>, |
| @@ -78,6 +84,9 @@ const ExecutorLayer = Layer.effect( · 50 unmodified lines | ||
| 78 | 84 | googleDiscoveryPlugin({ |
| 79 | 85 | bindingStore: makeKvGoogleDiscoveryBindingStore(kv, "google-discovery"), |
| 80 | 86 | }), |
| 87 | + graphqlPlugin({ | |
| 88 | + operationStore: makeKvGraphqlOperationStore(kv, "graphql"), | |
| 89 | + }), | |
| 81 | 90 | keychainPlugin(), |
| 82 | 91 | fileSecretsPlugin(), |
| 83 | 92 | onepasswordPlugin({ |
| @@ -7,6 +7,7 @@ · 6 unmodified lines | ||
| 7 | 7 | "@executor/plugin-onepassword": "workspace:*", |
| 8 | 8 | "@executor/plugin-mcp": "workspace:*", |
| 9 | 9 | "@executor/plugin-google-discovery": "workspace:*", |
| 10 | + "@executor/plugin-graphql": "workspace:*", | |
| 10 | 11 | "@executor/plugin-openapi": "workspace:*", |
| 11 | 12 | "@executor/sdk": "workspace:*", |
| 12 | 13 | "@executor/react": "workspace:*", |
| @@ -5,6 +5,7 @@ import type { SourcePlugin } from "@executor/react"; · 4 unmodified lines | ||
| 5 | 5 | import { openApiSourcePlugin } from "@executor/plugin-openapi/react"; |
| 6 | 6 | import { mcpSourcePlugin } from "@executor/plugin-mcp/react"; |
| 7 | 7 | import { googleDiscoverySourcePlugin } from "@executor/plugin-google-discovery/react"; |
| 8 | +import { graphqlSourcePlugin } from "@executor/plugin-graphql/react"; | |
| 8 | 9 | import { McpInstallCard } from "../components/mcp-install-card"; |
| 9 | 10 | |
| 10 | 11 | // --------------------------------------------------------------------------- |
| @@ -15,6 +16,7 @@ const sourcePlugins: SourcePlugin[] = [ · 4 unmodified lines | ||
| 15 | 16 | openApiSourcePlugin, |
| 16 | 17 | mcpSourcePlugin, |
| 17 | 18 | googleDiscoverySourcePlugin, |
| 19 | + graphqlSourcePlugin, | |
| 18 | 20 | ]; |
| 19 | 21 | |
| 20 | 22 | // --------------------------------------------------------------------------- |
| @@ -22,6 +22,7 @@ · 21 unmodified lines | ||
| 22 | 22 | "@executor/host-mcp": "workspace:*", |
| 23 | 23 | "@executor/plugin-file-secrets": "workspace:*", |
| 24 | 24 | "@executor/plugin-google-discovery": "workspace:*", |
| 25 | + "@executor/plugin-graphql": "workspace:*", | |
| 25 | 26 | "@executor/plugin-keychain": "workspace:*", |
| 26 | 27 | "@executor/plugin-mcp": "workspace:*", |
| 27 | 28 | "@executor/plugin-onepassword": "workspace:*", |
| @@ -42,6 +43,7 @@ · 14 unmodified lines | ||
| 42 | 43 | "version": "0.0.0", |
| 43 | 44 | "dependencies": { |
| 44 | 45 | "@executor/plugin-google-discovery": "workspace:*", |
| 46 | + "@executor/plugin-graphql": "workspace:*", | |
| 45 | 47 | "@executor/plugin-mcp": "workspace:*", |
| 46 | 48 | "@executor/plugin-onepassword": "workspace:*", |
| 47 | 49 | "@executor/plugin-openapi": "workspace:*", |
| @@ -251,6 +253,44 @@ · 203 unmodified lines | ||
| 251 | 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 | 294 | "packages/plugins/keychain": { |
| 255 | 295 | "name": "@executor/plugin-keychain", |
| 256 | 296 | "version": "0.0.0", |
| @@ -595,6 +635,8 @@ · 338 unmodified lines | ||
| 595 | 635 | |
| 596 | 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 | 640 | "@executor/plugin-keychain": ["@executor/plugin-keychain@workspace:packages/plugins/keychain"], |
| 599 | 641 | |
| 600 | 642 | "@executor/plugin-mcp": ["@executor/plugin-mcp@workspace:packages/plugins/mcp"], |
| @@ -9,7 +9,7 @@ import type { · 8 unmodified lines | ||
| 9 | 9 | InvokeOptions, |
| 10 | 10 | RuntimeToolHandler, |
| 11 | 11 | } from "../tools"; |
| 12 | −import { reattachDefs } from "../schema-refs"; | |
| 12 | +import { reattachDefs, normalizeRefs } from "../schema-refs"; | |
| 13 | 13 | |
| 14 | 14 | export const makeInMemoryToolRegistry = () => { |
| 15 | 15 | const tools = new Map<string, ToolRegistration>(); |
| @@ -83,14 +83,14 @@ export const makeInMemoryToolRegistry = () => { · 67 unmodified lines | ||
| 83 | 83 | registerDefinitions: (defs: Record<string, unknown>) => |
| 84 | 84 | Effect.sync(() => { |
| 85 | 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 | 90 | registerRuntimeDefinitions: (defs: Record<string, unknown>) => |
| 91 | 91 | Effect.sync(() => { |
| 92 | 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 | 142 | register: (newTools: readonly ToolRegistration[]) => |
| 143 | 143 | Effect.sync(() => { |
| 144 | 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 | 153 | registerRuntime: (newTools: readonly ToolRegistration[]) => |
| 150 | 154 | Effect.sync(() => { |
| 151 | 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 | 80 | } from "./plugins/in-memory-tools"; |
| 81 | 81 | |
| 82 | 82 | // Schema ref utilities |
| 83 | −export { hoistDefinitions, collectRefs, reattachDefs } from "./schema-refs"; | |
| 83 | +export { hoistDefinitions, collectRefs, reattachDefs, normalizeRefs } from "./schema-refs"; | |
| 84 | 84 | |
| 85 | 85 | // Runtime tools |
| 86 | 86 | export { |
| @@ -5,7 +5,60 @@ · 4 unmodified lines | ||
| 5 | 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 | 64 | * Extract `$defs`, `definitions`, and `components.schemas` from a JSON Schema, |
| @@ -18,27 +71,27 @@ export const hoistDefinitions = ( · 6 unmodified lines | ||
| 18 | 71 | if (schema == null || typeof schema !== "object") { |
| 19 | 72 | return { stripped: schema, defs: {} }; |
| 20 | 73 | } |
| 21 | − const obj = schema as JsonObj; | |
| 74 | + const obj = schema as Obj; | |
| 22 | 75 | const defs: Record<string, unknown> = {}; |
| 23 | 76 | |
| 24 | 77 | // $defs (JSON Schema draft 2019+, Effect) |
| 25 | 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 | 80 | defs[k] = v; |
| 28 | 81 | } |
| 29 | 82 | } |
| 30 | 83 | |
| 31 | 84 | // definitions (JSON Schema draft-07) |
| 32 | 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 | 87 | defs[k] = v; |
| 35 | 88 | } |
| 36 | 89 | } |
| 37 | 90 | |
| 38 | 91 | // components.schemas (OpenAPI) |
| 39 | − const components = obj.components as JsonObj | undefined; | |
| 92 | + const components = obj.components as Obj | undefined; | |
| 40 | 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 | 95 | defs[k] = v; |
| 43 | 96 | } |
| 44 | 97 | } |
| @@ -49,7 +102,7 @@ export const hoistDefinitions = ( · 4 unmodified lines | ||
| 49 | 102 | if (components && typeof components === "object") { |
| 50 | 103 | const { schemas: _s, ...otherComponents } = components; |
| 51 | 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 | 119 | found: Set<string> = new Set(), |
| 67 | 120 | ): Set<string> => { |
| 68 | 121 | if (node == null || typeof node !== "object") return found; |
| 69 | − const obj = node as JsonObj; | |
| 122 | + const obj = node as Obj; | |
| 70 | 123 | |
| 71 | 124 | if (typeof obj.$ref === "string") { |
| 72 | 125 | const name = parseRefName(obj.$ref); |
| @@ -93,6 +146,9 @@ export const collectRefs = ( · 20 unmodified lines | ||
| 93 | 146 | /** |
| 94 | 147 | * Re-attach only the referenced shared definitions into a schema, |
| 95 | 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 | 153 | export const reattachDefs = ( |
| 98 | 154 | schema: unknown, |
| @@ -108,13 +164,5 @@ export const reattachDefs = ( · 9 unmodified lines | ||
| 108 | 164 | if (def) attached[name] = def; |
| 109 | 165 | } |
| 110 | 166 | |
| 111 | − return { ...(schema as JsonObj), $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]; | |
| 167 | + return { ...(schema as Record<string, unknown>), $defs: attached }; | |
| 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 | 153 | const output = schema.outputSchema as Record<string, unknown>; |
| 154 | 154 | |
| 155 | 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 | 158 | expect(output["$defs"]).toBeDefined(); |
| 159 | 159 | const defs = output["$defs"] as Record<string, unknown>; |