History

graphql support

Rhys Sullivan committed 7 months ago

Commit 1ac1c191 parent 2d88a2d36 files · +2518 −25Browse files

Unified · Split

36 files
  1. AGENTS.md +1 −0
  2. CLAUDE.md +1 −0
  3. apps/server/package.json +1 −0
  4. apps/server/src/handlers/graphql.ts +17 −0
  5. apps/server/src/main.ts +5 −1
  6. apps/server/src/services/executor.ts +9 −0
  7. apps/web/package.json +1 −0
  8. apps/web/src/pages/sources.tsx +2 −0
  9. bun.lock +42 −0
  10. packages/core/sdk/src/in-memory/tool-registry.ts +13 −5
  11. packages/core/sdk/src/index.ts +1 −1
  12. packages/core/sdk/src/schema-refs.ts +65 −17
  13. packages/plugins/graphql/package.json +50 −0
  14. packages/plugins/graphql/src/api/group.ts +61 −0
  15. packages/plugins/graphql/src/api/handlers.ts +45 −0
  16. packages/plugins/graphql/src/api/index.ts +2 −0
  17. packages/plugins/graphql/src/react/AddGraphqlSource.tsx +275 −0
  18. packages/plugins/graphql/src/react/EditGraphqlSource.tsx +12 −0
  19. packages/plugins/graphql/src/react/GraphqlSourceSummary.tsx +9 −0
  20. packages/plugins/graphql/src/react/atoms.ts +7 −0
  21. packages/plugins/graphql/src/react/client.ts +20 −0
  22. packages/plugins/graphql/src/react/index.ts +3 −0
  23. packages/plugins/graphql/src/react/source-plugin.ts +14 −0
  24. packages/plugins/graphql/src/sdk/errors.ts +25 −0
  25. packages/plugins/graphql/src/sdk/extract.test.ts +260 −0
  26. packages/plugins/graphql/src/sdk/extract.ts +270 −0
  27. packages/plugins/graphql/src/sdk/index.ts +33 −0
  28. packages/plugins/graphql/src/sdk/introspect.ts +220 −0
  29. packages/plugins/graphql/src/sdk/invoke.ts +210 −0
  30. packages/plugins/graphql/src/sdk/kv-operation-store.ts +99 −0
  31. packages/plugins/graphql/src/sdk/operation-store.ts +38 −0
  32. packages/plugins/graphql/src/sdk/plugin.test.ts +195 −0
  33. packages/plugins/graphql/src/sdk/plugin.ts +392 −0
  34. packages/plugins/graphql/src/sdk/types.ts +92 −0
  35. packages/plugins/graphql/tsconfig.json +27 −0
  36. packages/plugins/openapi/src/sdk/real-specs.test.ts +1 −1
AGENTS.md+1−0
@@ -0,0 +1 @@
1use effect vitest and run tests via vitest not bun test
CLAUDE.md+1−0
@@ -0,0 +1 @@
1use effect vitest and run tests via vitest not bun test
apps/server/package.json+1−0
@@ -16,6 +16,7 @@ · 15 unmodified lines
16 "@executor/plugin-keychain": "workspace:*",16 "@executor/plugin-keychain": "workspace:*",
17 "@executor/plugin-onepassword": "workspace:*",17 "@executor/plugin-onepassword": "workspace:*",
18 "@executor/plugin-mcp": "workspace:*",18 "@executor/plugin-mcp": "workspace:*",
19 "@executor/plugin-graphql": "workspace:*",
19 "@executor/plugin-openapi": "workspace:*",20 "@executor/plugin-openapi": "workspace:*",
20 "@executor/sdk": "workspace:*",21 "@executor/sdk": "workspace:*",
21 "@executor/host-mcp": "workspace:*",22 "@executor/host-mcp": "workspace:*",
apps/server/src/handlers/graphql.ts+17−0
@@ -0,0 +1,17 @@
1import { Effect, Layer } from "effect";
2import {
3 GraphqlHandlers,
4 GraphqlExtensionService,
5} from "@executor/plugin-graphql/api";
6import { ExecutorService } from "../services/executor";
7
8// Wire GraphqlExtensionService from the executor's graphql extension
9const GraphqlExtensionLive = Layer.effect(
10 GraphqlExtensionService,
11 Effect.map(ExecutorService, (executor) => executor.graphql),
12);
13
14export const GraphqlHandlersLive = Layer.provide(
15 GraphqlHandlers,
16 GraphqlExtensionLive,
17);
apps/server/src/main.ts+5−1
@@ -11,6 +11,7 @@ import { OpenApiGroup } from "@executor/plugin-openapi/api"; · 10 unmodified lines
11import { McpGroup } from "@executor/plugin-mcp/api";11import { McpGroup } from "@executor/plugin-mcp/api";
12import { GoogleDiscoveryGroup } from "@executor/plugin-google-discovery/api";12import { GoogleDiscoveryGroup } from "@executor/plugin-google-discovery/api";
13import { OnePasswordGroup } from "@executor/plugin-onepassword/api";13import { OnePasswordGroup } from "@executor/plugin-onepassword/api";
14import { GraphqlGroup } from "@executor/plugin-graphql/api";
14import { ToolsHandlers } from "./handlers/tools";15import { ToolsHandlers } from "./handlers/tools";
15import { SourcesHandlers } from "./handlers/sources";16import { SourcesHandlers } from "./handlers/sources";
16import { SecretsHandlers } from "./handlers/secrets";17import { SecretsHandlers } from "./handlers/secrets";
@@ -18,6 +19,7 @@ import { OpenApiHandlersLive } from "./handlers/openapi"; · 1 unmodified line
18import { McpSourceHandlersLive } from "./handlers/mcp-source";19import { McpSourceHandlersLive } from "./handlers/mcp-source";
19import { GoogleDiscoveryHandlersLive } from "./handlers/google-discovery";20import { GoogleDiscoveryHandlersLive } from "./handlers/google-discovery";
20import { OnePasswordHandlersLive } from "./handlers/onepassword";21import { OnePasswordHandlersLive } from "./handlers/onepassword";
22import { GraphqlHandlersLive } from "./handlers/graphql";
21import { ExecutorService, ExecutorServiceLayer, getExecutor, type ServerExecutor } from "./services/executor";23import { ExecutorService, ExecutorServiceLayer, getExecutor, type ServerExecutor } from "./services/executor";
22import { createMcpRequestHandler, type McpRequestHandler } from "./mcp";24import { createMcpRequestHandler, type McpRequestHandler } from "./mcp";
23 25
@@ -28,7 +30,8 @@ import { createMcpRequestHandler, type McpRequestHandler } from "./mcp"; · 4 unmodified lines
28const ExecutorApiWithPlugins = addGroup(OpenApiGroup)30const ExecutorApiWithPlugins = addGroup(OpenApiGroup)
29 .add(McpGroup)31 .add(McpGroup)
30 .add(GoogleDiscoveryGroup)32 .add(GoogleDiscoveryGroup)
31 .add(OnePasswordGroup);33 .add(OnePasswordGroup)
34 .add(GraphqlGroup);
32 35
33// ---------------------------------------------------------------------------36// ---------------------------------------------------------------------------
34// API Layer37// API Layer
@@ -43,6 +46,7 @@ const ApiBase = HttpApiBuilder.api(ExecutorApiWithPlugins).pipe( · 8 unmodified lines
43 McpSourceHandlersLive,46 McpSourceHandlersLive,
44 GoogleDiscoveryHandlersLive,47 GoogleDiscoveryHandlersLive,
45 OnePasswordHandlersLive,48 OnePasswordHandlersLive,
49 GraphqlHandlersLive,
46 ]),50 ]),
47);51);
48 52
apps/server/src/services/executor.ts+9−0
@@ -12,6 +12,11 @@ import { · 11 unmodified lines
12 makeKvBindingStore as makeKvGoogleDiscoveryBindingStore,12 makeKvBindingStore as makeKvGoogleDiscoveryBindingStore,
13 type GoogleDiscoveryPluginExtension,13 type GoogleDiscoveryPluginExtension,
14} from "@executor/plugin-google-discovery";14} from "@executor/plugin-google-discovery";
15import {
16 graphqlPlugin,
17 makeKvOperationStore as makeKvGraphqlOperationStore,
18 type GraphqlPluginExtension,
19} from "@executor/plugin-graphql";
15import { keychainPlugin } from "@executor/plugin-keychain";20import { keychainPlugin } from "@executor/plugin-keychain";
16import { fileSecretsPlugin } from "@executor/plugin-file-secrets";21import { fileSecretsPlugin } from "@executor/plugin-file-secrets";
17import { onepasswordPlugin, type OnePasswordExtension } from "@executor/plugin-onepassword";22import { onepasswordPlugin, type OnePasswordExtension } from "@executor/plugin-onepassword";
@@ -22,6 +27,7 @@ type ServerPlugins = readonly [ · 4 unmodified lines
22 ExecutorPlugin<"openapi", OpenApiPluginExtension>,27 ExecutorPlugin<"openapi", OpenApiPluginExtension>,
23 ExecutorPlugin<"mcp", McpPluginExtension>,28 ExecutorPlugin<"mcp", McpPluginExtension>,
24 ExecutorPlugin<"googleDiscovery", GoogleDiscoveryPluginExtension>,29 ExecutorPlugin<"googleDiscovery", GoogleDiscoveryPluginExtension>,
30 ExecutorPlugin<"graphql", GraphqlPluginExtension>,
25 ReturnType<typeof keychainPlugin>,31 ReturnType<typeof keychainPlugin>,
26 ReturnType<typeof fileSecretsPlugin>,32 ReturnType<typeof fileSecretsPlugin>,
27 ExecutorPlugin<"onepassword", OnePasswordExtension>,33 ExecutorPlugin<"onepassword", OnePasswordExtension>,
@@ -78,6 +84,9 @@ const ExecutorLayer = Layer.effect( · 50 unmodified lines
78 googleDiscoveryPlugin({84 googleDiscoveryPlugin({
79 bindingStore: makeKvGoogleDiscoveryBindingStore(kv, "google-discovery"),85 bindingStore: makeKvGoogleDiscoveryBindingStore(kv, "google-discovery"),
80 }),86 }),
87 graphqlPlugin({
88 operationStore: makeKvGraphqlOperationStore(kv, "graphql"),
89 }),
81 keychainPlugin(),90 keychainPlugin(),
82 fileSecretsPlugin(),91 fileSecretsPlugin(),
83 onepasswordPlugin({92 onepasswordPlugin({
apps/web/package.json+1−0
@@ -7,6 +7,7 @@ · 6 unmodified lines
7 "@executor/plugin-onepassword": "workspace:*",7 "@executor/plugin-onepassword": "workspace:*",
8 "@executor/plugin-mcp": "workspace:*",8 "@executor/plugin-mcp": "workspace:*",
9 "@executor/plugin-google-discovery": "workspace:*",9 "@executor/plugin-google-discovery": "workspace:*",
10 "@executor/plugin-graphql": "workspace:*",
10 "@executor/plugin-openapi": "workspace:*",11 "@executor/plugin-openapi": "workspace:*",
11 "@executor/sdk": "workspace:*",12 "@executor/sdk": "workspace:*",
12 "@executor/react": "workspace:*",13 "@executor/react": "workspace:*",
apps/web/src/pages/sources.tsx+2−0
@@ -5,6 +5,7 @@ import type { SourcePlugin } from "@executor/react"; · 4 unmodified lines
5import { openApiSourcePlugin } from "@executor/plugin-openapi/react";5import { openApiSourcePlugin } from "@executor/plugin-openapi/react";
6import { mcpSourcePlugin } from "@executor/plugin-mcp/react";6import { mcpSourcePlugin } from "@executor/plugin-mcp/react";
7import { googleDiscoverySourcePlugin } from "@executor/plugin-google-discovery/react";7import { googleDiscoverySourcePlugin } from "@executor/plugin-google-discovery/react";
8import { graphqlSourcePlugin } from "@executor/plugin-graphql/react";
8import { McpInstallCard } from "../components/mcp-install-card";9import { McpInstallCard } from "../components/mcp-install-card";
9 10
10// ---------------------------------------------------------------------------11// ---------------------------------------------------------------------------
@@ -15,6 +16,7 @@ const sourcePlugins: SourcePlugin[] = [ · 4 unmodified lines
15 openApiSourcePlugin,16 openApiSourcePlugin,
16 mcpSourcePlugin,17 mcpSourcePlugin,
17 googleDiscoverySourcePlugin,18 googleDiscoverySourcePlugin,
19 graphqlSourcePlugin,
18];20];
19 21
20// ---------------------------------------------------------------------------22// ---------------------------------------------------------------------------
bun.lock+42−0
@@ -22,6 +22,7 @@ · 21 unmodified lines
22 "@executor/host-mcp": "workspace:*",22 "@executor/host-mcp": "workspace:*",
23 "@executor/plugin-file-secrets": "workspace:*",23 "@executor/plugin-file-secrets": "workspace:*",
24 "@executor/plugin-google-discovery": "workspace:*",24 "@executor/plugin-google-discovery": "workspace:*",
25 "@executor/plugin-graphql": "workspace:*",
25 "@executor/plugin-keychain": "workspace:*",26 "@executor/plugin-keychain": "workspace:*",
26 "@executor/plugin-mcp": "workspace:*",27 "@executor/plugin-mcp": "workspace:*",
27 "@executor/plugin-onepassword": "workspace:*",28 "@executor/plugin-onepassword": "workspace:*",
@@ -42,6 +43,7 @@ · 14 unmodified lines
42 "version": "0.0.0",43 "version": "0.0.0",
43 "dependencies": {44 "dependencies": {
44 "@executor/plugin-google-discovery": "workspace:*",45 "@executor/plugin-google-discovery": "workspace:*",
46 "@executor/plugin-graphql": "workspace:*",
45 "@executor/plugin-mcp": "workspace:*",47 "@executor/plugin-mcp": "workspace:*",
46 "@executor/plugin-onepassword": "workspace:*",48 "@executor/plugin-onepassword": "workspace:*",
47 "@executor/plugin-openapi": "workspace:*",49 "@executor/plugin-openapi": "workspace:*",
@@ -251,6 +253,44 @@ · 203 unmodified lines
251 "react",253 "react",
252 ],254 ],
253 },255 },
256 "packages/plugins/graphql": {
257 "name": "@executor/plugin-graphql",
258 "version": "0.0.0",
259 "dependencies": {
260 "@effect/platform": "catalog:",
261 "@effect/platform-node": "catalog:",
262 "@executor/sdk": "workspace:*",
263 "effect": "catalog:",
264 },
265 "devDependencies": {
266 "@effect-atom/atom-react": "^0.5.0",
267 "@effect/vitest": "catalog:",
268 "@executor/api": "workspace:*",
269 "@executor/react": "workspace:*",
270 "@executor/ui": "workspace:*",
271 "@types/node": "catalog:",
272 "@types/react": "^19.1.0",
273 "bun-types": "catalog:",
274 "react": "^19.1.0",
275 "vitest": "catalog:",
276 },
277 "peerDependencies": {
278 "@effect-atom/atom-react": "^0.5.0",
279 "@executor/api": "workspace:*",
280 "@executor/react": "workspace:*",
281 "@executor/ui": "workspace:*",
282 "@tanstack/react-router": "^1.168.10",
283 "react": "^19.1.0",
284 },
285 "optionalPeers": [
286 "@effect-atom/atom-react",
287 "@executor/api",
288 "@executor/react",
289 "@executor/ui",
290 "@tanstack/react-router",
291 "react",
292 ],
293 },
254 "packages/plugins/keychain": {294 "packages/plugins/keychain": {
255 "name": "@executor/plugin-keychain",295 "name": "@executor/plugin-keychain",
256 "version": "0.0.0",296 "version": "0.0.0",
@@ -595,6 +635,8 @@ · 338 unmodified lines
595 635
596 "@executor/plugin-google-discovery": ["@executor/plugin-google-discovery@workspace:packages/plugins/google-discovery"],636 "@executor/plugin-google-discovery": ["@executor/plugin-google-discovery@workspace:packages/plugins/google-discovery"],
597 637
638 "@executor/plugin-graphql": ["@executor/plugin-graphql@workspace:packages/plugins/graphql"],
639
598 "@executor/plugin-keychain": ["@executor/plugin-keychain@workspace:packages/plugins/keychain"],640 "@executor/plugin-keychain": ["@executor/plugin-keychain@workspace:packages/plugins/keychain"],
599 641
600 "@executor/plugin-mcp": ["@executor/plugin-mcp@workspace:packages/plugins/mcp"],642 "@executor/plugin-mcp": ["@executor/plugin-mcp@workspace:packages/plugins/mcp"],
packages/core/sdk/src/in-memory/tool-registry.ts+13−5
@@ -9,7 +9,7 @@ import type { · 8 unmodified lines
9 InvokeOptions,9 InvokeOptions,
10 RuntimeToolHandler,10 RuntimeToolHandler,
11} from "../tools";11} from "../tools";
12import { reattachDefs } from "../schema-refs";12import { reattachDefs, normalizeRefs } from "../schema-refs";
13 13
14export const makeInMemoryToolRegistry = () => {14export const makeInMemoryToolRegistry = () => {
15 const tools = new Map<string, ToolRegistration>();15 const tools = new Map<string, ToolRegistration>();
@@ -83,14 +83,14 @@ export const makeInMemoryToolRegistry = () => { · 67 unmodified lines
83 registerDefinitions: (defs: Record<string, unknown>) =>83 registerDefinitions: (defs: Record<string, unknown>) =>
84 Effect.sync(() => {84 Effect.sync(() => {
85 for (const [k, v] of Object.entries(defs)) {85 for (const [k, v] of Object.entries(defs)) {
86 sharedDefs.set(k, v);86 sharedDefs.set(k, normalizeRefs(v));
87 }87 }
88 }),88 }),
89 89
90 registerRuntimeDefinitions: (defs: Record<string, unknown>) =>90 registerRuntimeDefinitions: (defs: Record<string, unknown>) =>
91 Effect.sync(() => {91 Effect.sync(() => {
92 for (const [k, v] of Object.entries(defs)) {92 for (const [k, v] of Object.entries(defs)) {
93 runtimeDefs.set(k, v);93 runtimeDefs.set(k, normalizeRefs(v));
94 }94 }
95 }),95 }),
96 96
@@ -142,14 +142,22 @@ export const makeInMemoryToolRegistry = () => { · 45 unmodified lines
142 register: (newTools: readonly ToolRegistration[]) =>142 register: (newTools: readonly ToolRegistration[]) =>
143 Effect.sync(() => {143 Effect.sync(() => {
144 for (const t of newTools) {144 for (const t of newTools) {
145 tools.set(t.id, t);145 tools.set(t.id, {
146 ...t,
147 inputSchema: normalizeRefs(t.inputSchema),
148 outputSchema: normalizeRefs(t.outputSchema),
149 });
146 }150 }
147 }),151 }),
148 152
149 registerRuntime: (newTools: readonly ToolRegistration[]) =>153 registerRuntime: (newTools: readonly ToolRegistration[]) =>
150 Effect.sync(() => {154 Effect.sync(() => {
151 for (const t of newTools) {155 for (const t of newTools) {
152 runtimeTools.set(t.id, t);156 runtimeTools.set(t.id, {
157 ...t,
158 inputSchema: normalizeRefs(t.inputSchema),
159 outputSchema: normalizeRefs(t.outputSchema),
160 });
153 }161 }
154 }),162 }),
155 163
packages/core/sdk/src/index.ts+1−1
@@ -80,7 +80,7 @@ export { · 79 unmodified lines
80} from "./plugins/in-memory-tools";80} from "./plugins/in-memory-tools";
81 81
82// Schema ref utilities82// Schema ref utilities
83export { hoistDefinitions, collectRefs, reattachDefs } from "./schema-refs";83export { hoistDefinitions, collectRefs, reattachDefs, normalizeRefs } from "./schema-refs";
84 84
85// Runtime tools85// Runtime tools
86export {86export {
packages/core/sdk/src/schema-refs.ts+65−17
@@ -5,7 +5,60 @@ · 4 unmodified lines
5// Used by any ToolRegistry implementation (in-memory, database-backed, etc.)5// Used by any ToolRegistry implementation (in-memory, database-backed, etc.)
6// ---------------------------------------------------------------------------6// ---------------------------------------------------------------------------
7 7
8type JsonObj = Record<string, unknown>;8type Obj = Record<string, unknown>;
9
10/** Canonical $ref prefix used internally. */
11const CANONICAL_PREFIX = "#/$defs/";
12
13/** Patterns we accept as equivalent $ref pointers into shared definitions. */
14const REF_PATTERN = /^#\/(?:\$defs|definitions|components\/schemas)\/(.+)$/;
15
16/** Extract the definition name from a $ref pointer */
17const 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 */
24const 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 */
33export const normalizeRefs = (node: unknown): unknown => {
34 if (node == null || typeof node !== "object") return node;
35 if (Array.isArray(node)) {
36 let changed = false;
37 const out = node.map((item) => {
38 const n = normalizeRefs(item);
39 if (n !== item) changed = true;
40 return n;
41 });
42 return changed ? out : node;
43 }
44
45 const obj = node as Obj;
46
47 // Fast path: $ref node — only rewrite the pointer, shallow copy rest
48 if (typeof obj.$ref === "string") {
49 const normalized = normalizeRef(obj.$ref);
50 return normalized !== obj.$ref ? { ...obj, $ref: normalized } : obj;
51 }
52
53 let changed = false;
54 const result: Obj = {};
55 for (const [k, v] of Object.entries(obj)) {
56 const n = normalizeRefs(v);
57 if (n !== v) changed = true;
58 result[k] = n;
59 }
60 return changed ? result : obj;
61};
9 62
10/**63/**
11 * Extract `$defs`, `definitions`, and `components.schemas` from a JSON Schema,64 * Extract `$defs`, `definitions`, and `components.schemas` from a JSON Schema,
@@ -18,27 +71,27 @@ export const hoistDefinitions = ( · 6 unmodified lines
18 if (schema == null || typeof schema !== "object") {71 if (schema == null || typeof schema !== "object") {
19 return { stripped: schema, defs: {} };72 return { stripped: schema, defs: {} };
20 }73 }
21 const obj = schema as JsonObj;74 const obj = schema as Obj;
22 const defs: Record<string, unknown> = {};75 const defs: Record<string, unknown> = {};
23 76
24 // $defs (JSON Schema draft 2019+, Effect)77 // $defs (JSON Schema draft 2019+, Effect)
25 if (obj.$defs && typeof obj.$defs === "object") {78 if (obj.$defs && typeof obj.$defs === "object") {
26 for (const [k, v] of Object.entries(obj.$defs as JsonObj)) {79 for (const [k, v] of Object.entries(obj.$defs as Obj)) {
27 defs[k] = v;80 defs[k] = v;
28 }81 }
29 }82 }
30 83
31 // definitions (JSON Schema draft-07)84 // definitions (JSON Schema draft-07)
32 if (obj.definitions && typeof obj.definitions === "object") {85 if (obj.definitions && typeof obj.definitions === "object") {
33 for (const [k, v] of Object.entries(obj.definitions as JsonObj)) {86 for (const [k, v] of Object.entries(obj.definitions as Obj)) {
34 defs[k] = v;87 defs[k] = v;
35 }88 }
36 }89 }
37 90
38 // components.schemas (OpenAPI)91 // components.schemas (OpenAPI)
39 const components = obj.components as JsonObj | undefined;92 const components = obj.components as Obj | undefined;
40 if (components?.schemas && typeof components.schemas === "object") {93 if (components?.schemas && typeof components.schemas === "object") {
41 for (const [k, v] of Object.entries(components.schemas as JsonObj)) {94 for (const [k, v] of Object.entries(components.schemas as Obj)) {
42 defs[k] = v;95 defs[k] = v;
43 }96 }
44 }97 }
@@ -49,7 +102,7 @@ export const hoistDefinitions = ( · 4 unmodified lines
49 if (components && typeof components === "object") {102 if (components && typeof components === "object") {
50 const { schemas: _s, ...otherComponents } = components;103 const { schemas: _s, ...otherComponents } = components;
51 if (Object.keys(otherComponents).length > 0) {104 if (Object.keys(otherComponents).length > 0) {
52 (rest as JsonObj).components = otherComponents;105 (rest as Obj).components = otherComponents;
53 }106 }
54 }107 }
55 108
@@ -66,7 +119,7 @@ export const collectRefs = ( · 10 unmodified lines
66 found: Set<string> = new Set(),119 found: Set<string> = new Set(),
67): Set<string> => {120): Set<string> => {
68 if (node == null || typeof node !== "object") return found;121 if (node == null || typeof node !== "object") return found;
69 const obj = node as JsonObj;122 const obj = node as Obj;
70 123
71 if (typeof obj.$ref === "string") {124 if (typeof obj.$ref === "string") {
72 const name = parseRefName(obj.$ref);125 const name = parseRefName(obj.$ref);
@@ -93,6 +146,9 @@ export const collectRefs = ( · 20 unmodified lines
93/**146/**
94 * Re-attach only the referenced shared definitions into a schema,147 * Re-attach only the referenced shared definitions into a schema,
95 * so the caller gets a self-contained, usable JSON Schema.148 * so the caller gets a self-contained, usable JSON Schema.
149 *
150 * Assumes all `$ref` pointers and definitions have already been normalized
151 * to `#/$defs/<name>` form at registration time.
96 */152 */
97export const reattachDefs = (153export const reattachDefs = (
98 schema: unknown,154 schema: unknown,
@@ -108,13 +164,5 @@ export const reattachDefs = ( · 9 unmodified lines
108 if (def) attached[name] = def;164 if (def) attached[name] = def;
109 }165 }
110 166
111 return { ...(schema as JsonObj), $defs: attached };167 return { ...(schema as Record<string, unknown>), $defs: attached };
112};
113
114/** Extract the definition name from a $ref pointer */
115const parseRefName = (ref: string): string | undefined => {
116 const match = ref.match(
117 /^#\/(?:\$defs|definitions|components\/schemas)\/(.+)$/,
118 );
119 return match?.[1];
120};168};
packages/plugins/graphql/package.json+50−0
@@ -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}
packages/plugins/graphql/src/api/group.ts+61−0
@@ -0,0 +1,61 @@
1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2import { Schema } from "effect";
3import { ScopeId } from "@executor/sdk";
4
5import {
6 GraphqlIntrospectionError,
7 GraphqlExtractionError,
8} from "../sdk/errors";
9
10// ---------------------------------------------------------------------------
11// Params
12// ---------------------------------------------------------------------------
13
14const scopeIdParam = HttpApiSchema.param("scopeId", ScopeId);
15
16// ---------------------------------------------------------------------------
17// Payloads
18// ---------------------------------------------------------------------------
19
20const 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
33const AddSourceResponse = Schema.Struct({
34 toolCount: Schema.Number,
35 namespace: Schema.String,
36});
37
38// ---------------------------------------------------------------------------
39// Errors with HTTP status
40// ---------------------------------------------------------------------------
41
42const IntrospectionError = GraphqlIntrospectionError.annotations(
43 HttpApiSchema.annotations({ status: 400 }),
44);
45const ExtractionError = GraphqlExtractionError.annotations(
46 HttpApiSchema.annotations({ status: 400 }),
47);
48
49// ---------------------------------------------------------------------------
50// Group
51// ---------------------------------------------------------------------------
52
53export 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") {}
packages/plugins/graphql/src/api/handlers.ts+45−0
@@ -0,0 +1,45 @@
1import { HttpApiBuilder } from "@effect/platform";
2import { Context, Effect } from "effect";
3
4import { addGroup } from "@executor/api";
5import type { GraphqlPluginExtension, HeaderValue } from "../sdk/plugin";
6import { GraphqlGroup } from "./group";
7
8// ---------------------------------------------------------------------------
9// Service tag — the server provides the GraphQL extension
10// ---------------------------------------------------------------------------
11
12export class GraphqlExtensionService extends Context.Tag(
13 "GraphqlExtensionService",
14)<GraphqlExtensionService, GraphqlPluginExtension>() {}
15
16// ---------------------------------------------------------------------------
17// Composed API — core + graphql group
18// ---------------------------------------------------------------------------
19
20const ExecutorApiWithGraphql = addGroup(GraphqlGroup);
21
22// ---------------------------------------------------------------------------
23// Handlers
24// ---------------------------------------------------------------------------
25
26export 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);
packages/plugins/graphql/src/api/index.ts+2−0
@@ -0,0 +1,2 @@
1export { GraphqlGroup } from "./group";
2export { GraphqlHandlers, GraphqlExtensionService } from "./handlers";
packages/plugins/graphql/src/react/AddGraphqlSource.tsx+275−0
@@ -0,0 +1,275 @@
1import { useState } from "react";
2import { useAtomSet, useAtomValue, useAtomRefresh, Result } from "@effect-atom/atom-react";
3
4import {
5 secretsAtom,
6 setSecret,
7 ScopeId,
8 SecretPicker,
9 type SecretPickerSecret,
10} from "@executor/react";
11import { SecretId } from "@executor/sdk";
12import { Button } from "@executor/ui/components/button";
13import { Input } from "@executor/ui/components/input";
14import { Label } from "@executor/ui/components/label";
15import { Spinner } from "@executor/ui/components/spinner";
16import { addGraphqlSource } from "./atoms";
17import type { HeaderValue } from "../sdk/types";
18
19// ---------------------------------------------------------------------------
20// Inline secret creation
21// ---------------------------------------------------------------------------
22
23function 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
113function 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
157export 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}
packages/plugins/graphql/src/react/EditGraphqlSource.tsx+12−0
@@ -0,0 +1,12 @@
1export 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}
packages/plugins/graphql/src/react/GraphqlSourceSummary.tsx+9−0
@@ -0,0 +1,9 @@
1export default function GraphqlSourceSummary(props: {
2 sourceId: string;
3}) {
4 return (
5 <span>
6 GraphQL · {props.sourceId}
7 </span>
8 );
9}
packages/plugins/graphql/src/react/atoms.ts+7−0
@@ -0,0 +1,7 @@
1import { GraphqlClient } from "./client";
2
3// ---------------------------------------------------------------------------
4// Mutation atoms
5// ---------------------------------------------------------------------------
6
7export const addGraphqlSource = GraphqlClient.mutation("graphql", "addSource");
packages/plugins/graphql/src/react/client.ts+20−0
@@ -0,0 +1,20 @@
1import { AtomHttpApi } from "@effect-atom/atom-react";
2import { FetchHttpClient } from "@effect/platform";
3import { addGroup } from "@executor/api";
4import { getBaseUrl } from "@executor/react";
5import { GraphqlGroup } from "../api/group";
6
7// ---------------------------------------------------------------------------
8// GraphQL-aware client — core routes + graphql routes
9// ---------------------------------------------------------------------------
10
11const GraphqlApi = addGroup(GraphqlGroup);
12
13export const GraphqlClient = AtomHttpApi.Tag<"GraphqlClient">()(
14 "GraphqlClient",
15 {
16 api: GraphqlApi,
17 httpClient: FetchHttpClient.layer,
18 baseUrl: getBaseUrl(),
19 },
20);
packages/plugins/graphql/src/react/index.ts+3−0
@@ -0,0 +1,3 @@
1export { graphqlSourcePlugin } from "./source-plugin";
2export { GraphqlClient } from "./client";
3export { addGraphqlSource } from "./atoms";
packages/plugins/graphql/src/react/source-plugin.ts+14−0
@@ -0,0 +1,14 @@
1import { lazy } from "react";
2import type { SourcePlugin } from "@executor/react";
3
4// ---------------------------------------------------------------------------
5// GraphQL source plugin — lazy-loaded components
6// ---------------------------------------------------------------------------
7
8export 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};
packages/plugins/graphql/src/sdk/errors.ts+25−0
@@ -0,0 +1,25 @@
1import { Schema } from "effect";
2
3export class GraphqlIntrospectionError extends Schema.TaggedError<GraphqlIntrospectionError>()(
4 "GraphqlIntrospectionError",
5 {
6 message: Schema.String,
7 error: Schema.Defect,
8 },
9) {}
10
11export class GraphqlExtractionError extends Schema.TaggedError<GraphqlExtractionError>()(
12 "GraphqlExtractionError",
13 {
14 message: Schema.String,
15 },
16) {}
17
18export 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) {}
packages/plugins/graphql/src/sdk/extract.test.ts+260−0
@@ -0,0 +1,260 @@
1import { describe, it, expect } from "@effect/vitest";
2import { Effect } from "effect";
3
4import { extract } from "./extract";
5import type { IntrospectionResult } from "./introspect";
6
7// ---------------------------------------------------------------------------
8// Minimal introspection fixture
9// ---------------------------------------------------------------------------
10
11const 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
104describe("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});
packages/plugins/graphql/src/sdk/extract.ts+270−0
@@ -0,0 +1,270 @@
1import { Effect, Option } from "effect";
2
3import { GraphqlExtractionError } from "./errors";
4import type {
5 IntrospectionResult,
6 IntrospectionSchema,
7 IntrospectionType,
8 IntrospectionTypeRef,
9 IntrospectionInputValue,
10} from "./introspect";
11import {
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 */
23const 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) */
30const isNonNull = (ref: IntrospectionTypeRef): boolean =>
31 ref.kind === "NON_NULL";
32
33// ---------------------------------------------------------------------------
34// Build shared definitions from all INPUT_OBJECT and ENUM types
35// ---------------------------------------------------------------------------
36
37const 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
83const 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
118const 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
138const 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!]!") */
167const 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
182const 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
225export 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
232export 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 });
packages/plugins/graphql/src/sdk/index.ts+33−0
@@ -0,0 +1,33 @@
1export { introspect, parseIntrospectionJson } from "./introspect";
2export { extract, type ExtractionOutput } from "./extract";
3export { invoke, makeGraphqlInvoker } from "./invoke";
4export {
5 graphqlPlugin,
6 type GraphqlSourceConfig,
7 type GraphqlPluginExtension,
8} from "./plugin";
9export {
10 type GraphqlOperationStore,
11 type SourceMeta,
12} from "./operation-store";
13export {
14 makeKvOperationStore,
15 makeInMemoryOperationStore,
16} from "./kv-operation-store";
17
18export {
19 GraphqlIntrospectionError,
20 GraphqlExtractionError,
21 GraphqlInvocationError,
22} from "./errors";
23
24export {
25 ExtractedField,
26 ExtractionResult,
27 GraphqlArgument,
28 GraphqlOperationKind,
29 InvocationConfig,
30 InvocationResult,
31 OperationBinding,
32 HeaderValue,
33} from "./types";
packages/plugins/graphql/src/sdk/introspect.ts+220−0
@@ -0,0 +1,220 @@
1import { Effect } from "effect";
2import { HttpClient, HttpClientRequest } from "@effect/platform";
3
4import { GraphqlIntrospectionError } from "./errors";
5
6// ---------------------------------------------------------------------------
7// Introspection query — standard GraphQL introspection
8// ---------------------------------------------------------------------------
9
10const 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
84export interface IntrospectionTypeRef {
85 readonly kind: string;
86 readonly name: string | null;
87 readonly ofType: IntrospectionTypeRef | null;
88}
89
90export interface IntrospectionInputValue {
91 readonly name: string;
92 readonly description: string | null;
93 readonly type: IntrospectionTypeRef;
94 readonly defaultValue: string | null;
95}
96
97export interface IntrospectionField {
98 readonly name: string;
99 readonly description: string | null;
100 readonly args: readonly IntrospectionInputValue[];
101 readonly type: IntrospectionTypeRef;
102}
103
104export interface IntrospectionEnumValue {
105 readonly name: string;
106 readonly description: string | null;
107}
108
109export 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
118export interface IntrospectionSchema {
119 readonly queryType: { readonly name: string } | null;
120 readonly mutationType: { readonly name: string } | null;
121 readonly types: readonly IntrospectionType[];
122}
123
124export interface IntrospectionResult {
125 readonly __schema: IntrospectionSchema;
126}
127
128// ---------------------------------------------------------------------------
129// Introspect a GraphQL endpoint
130// ---------------------------------------------------------------------------
131
132export 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
202export 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 });
packages/plugins/graphql/src/sdk/invoke.ts+210−0
@@ -0,0 +1,210 @@
1import { Effect, Layer, Option } from "effect";
2import { HttpClient, HttpClientRequest } from "@effect/platform";
3
4import {
5 type ToolId,
6 type ToolInvoker,
7 ToolInvocationResult,
8 ToolInvocationError,
9 type ScopeId,
10 type SecretId,
11} from "@executor/sdk";
12
13import { GraphqlInvocationError } from "./errors";
14import type { GraphqlOperationStore } from "./operation-store";
15import {
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
26const 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
56const 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
70export 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
139export 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});
packages/plugins/graphql/src/sdk/kv-operation-store.ts+99−0
@@ -0,0 +1,99 @@
1// ---------------------------------------------------------------------------
2// KV-backed GraphqlOperationStore
3// ---------------------------------------------------------------------------
4
5import { Effect, Schema } from "effect";
6import { scopeKv, makeInMemoryScopedKv, type Kv, type ToolId, type ScopedKv } from "@executor/sdk";
7
8import type { GraphqlOperationStore, SourceMeta } from "./operation-store";
9import { OperationBinding, InvocationConfig } from "./types";
10
11// ---------------------------------------------------------------------------
12// Stored entry schema
13// ---------------------------------------------------------------------------
14
15class StoredEntry extends Schema.Class<StoredEntry>("StoredEntry")({
16 namespace: Schema.String,
17 binding: OperationBinding,
18 config: InvocationConfig,
19}) {}
20
21const encodeEntry = Schema.encodeSync(Schema.parseJson(StoredEntry));
22const decodeEntry = Schema.decodeUnknownSync(Schema.parseJson(StoredEntry));
23
24// ---------------------------------------------------------------------------
25// Implementation
26// ---------------------------------------------------------------------------
27
28const 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
89export const makeKvOperationStore = (
90 kv: Kv,
91 namespace: string,
92): GraphqlOperationStore =>
93 makeStore(
94 scopeKv(kv, `${namespace}.bindings`),
95 scopeKv(kv, `${namespace}.sources`),
96 );
97
98export const makeInMemoryOperationStore = (): GraphqlOperationStore =>
99 makeStore(makeInMemoryScopedKv(), makeInMemoryScopedKv());
packages/plugins/graphql/src/sdk/operation-store.ts+38−0
@@ -0,0 +1,38 @@
1import type { Effect } from "effect";
2import type { ToolId } from "@executor/sdk";
3
4import type { OperationBinding, InvocationConfig } from "./types";
5
6// ---------------------------------------------------------------------------
7// Operation store — plugin's own storage for invocation data
8// ---------------------------------------------------------------------------
9
10export interface SourceMeta {
11 readonly namespace: string;
12 readonly name: string;
13}
14
15export 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}
packages/plugins/graphql/src/sdk/plugin.test.ts+195−0
@@ -0,0 +1,195 @@
1import { describe, it, expect } from "@effect/vitest";
2import { Effect } from "effect";
3
4import {
5 createExecutor,
6 makeTestConfig,
7} from "@executor/sdk";
8import { graphqlPlugin } from "./plugin";
9import type { IntrospectionResult } from "./introspect";
10
11// ---------------------------------------------------------------------------
12// Mock introspection response
13// ---------------------------------------------------------------------------
14
15const 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
73describe("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});
packages/plugins/graphql/src/sdk/plugin.ts+392−0
@@ -0,0 +1,392 @@
1import { Effect, Option, Schema } from "effect";
2import { FetchHttpClient, HttpClient } from "@effect/platform";
3import type { Layer } from "effect";
4
5import {
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
17import { introspect, parseIntrospectionJson, type IntrospectionResult, type IntrospectionType, type IntrospectionField } from "./introspect";
18import { extract } from "./extract";
19import { makeGraphqlInvoker } from "./invoke";
20import type { GraphqlOperationStore } from "./operation-store";
21import { makeInMemoryOperationStore } from "./kv-operation-store";
22import {
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
35export type HeaderValue = HeaderValueValue;
36
37export 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
52export 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
66const 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});
74type AddSourceInput = typeof AddSourceInputSchema.Type;
75
76const AddSourceOutputSchema = Schema.Struct({
77 sourceId: Schema.String,
78 toolCount: Schema.Number,
79});
80
81/** Derive a namespace from an endpoint URL */
82const 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
91const 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
112import type { IntrospectionTypeRef } from "./introspect";
113
114const 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
125const unwrapTypeName = (ref: IntrospectionTypeRef): string => {
126 if (ref.name) return ref.name;
127 if (ref.ofType) return unwrapTypeName(ref.ofType);
128 return "Unknown";
129};
130
131const 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
163const 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
188export 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};
packages/plugins/graphql/src/sdk/types.ts+92−0
@@ -0,0 +1,92 @@
1import { Schema } from "effect";
2
3// ---------------------------------------------------------------------------
4// GraphQL operation kind
5// ---------------------------------------------------------------------------
6
7export const GraphqlOperationKind = Schema.Literal("query", "mutation");
8export type GraphqlOperationKind = typeof GraphqlOperationKind.Type;
9
10// ---------------------------------------------------------------------------
11// Extracted field (becomes a tool)
12// ---------------------------------------------------------------------------
13
14export 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
23export 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
38export 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
50export 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
65export const HeaderValue = Schema.Union(
66 Schema.String,
67 Schema.Struct({
68 secretId: Schema.String,
69 prefix: Schema.optional(Schema.String),
70 }),
71);
72export type HeaderValue = typeof HeaderValue.Type;
73
74export 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
86export 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}) {}
packages/plugins/graphql/tsconfig.json+27−0
@@ -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}
packages/plugins/openapi/src/sdk/real-specs.test.ts+1−1
@@ -153,7 +153,7 @@ describe("Real specs: Cloudflare API", () => { · 152 unmodified lines
153 const output = schema.outputSchema as Record<string, unknown>;153 const output = schema.outputSchema as Record<string, unknown>;
154 154
155 expect(output["$ref"]).toBeTypeOf("string");155 expect(output["$ref"]).toBeTypeOf("string");
156 expect((output["$ref"] as string).startsWith("#/components/schemas/")).toBe(true);156 expect((output["$ref"] as string).startsWith("#/$defs/")).toBe(true);
157 157
158 expect(output["$defs"]).toBeDefined();158 expect(output["$defs"]).toBeDefined();
159 const defs = output["$defs"] as Record<string, unknown>;159 const defs = output["$defs"] as Record<string, unknown>;