From 140173d80e96b350907e296d4905c6b95c06768a Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 9 Apr 2026 21:54:54 -0700 Subject: [PATCH] feat(graphql): add edit source configuration support Implement getSource/update for GraphQL with typed API routes, config-file store, stored-source schema, and edit UI using shared SecretHeaderAuthRow. --- bun.lock | 1 + packages/plugins/graphql/package.json | 1 + packages/plugins/graphql/src/api/group.ts | 22 +++ packages/plugins/graphql/src/api/handlers.ts | 49 ++++-- .../graphql/src/react/EditGraphqlSource.tsx | 151 +++++++++++++++++- packages/plugins/graphql/src/react/atoms.ts | 13 ++ .../graphql/src/sdk/config-file-store.ts | 69 ++++++++ packages/plugins/graphql/src/sdk/index.ts | 1 + .../graphql/src/sdk/kv-operation-store.ts | 30 ++-- .../graphql/src/sdk/operation-store.ts | 8 + packages/plugins/graphql/src/sdk/plugin.ts | 56 ++++++- .../plugins/graphql/src/sdk/stored-source.ts | 25 +++ 12 files changed, 391 insertions(+), 35 deletions(-) create mode 100644 packages/plugins/graphql/src/sdk/config-file-store.ts create mode 100644 packages/plugins/graphql/src/sdk/stored-source.ts diff --git a/bun.lock b/bun.lock index 47f4144a75..645008d0c0 100644 --- a/bun.lock +++ b/bun.lock @@ -441,6 +441,7 @@ "dependencies": { "@effect/platform": "catalog:", "@effect/platform-node": "catalog:", + "@executor/config": "workspace:*", "@executor/sdk": "workspace:*", "effect": "catalog:", }, diff --git a/packages/plugins/graphql/package.json b/packages/plugins/graphql/package.json index cd525be3d9..6f390e5518 100644 --- a/packages/plugins/graphql/package.json +++ b/packages/plugins/graphql/package.json @@ -28,6 +28,7 @@ "dependencies": { "@effect/platform": "catalog:", "@effect/platform-node": "catalog:", + "@executor/config": "workspace:*", "@executor/sdk": "workspace:*", "effect": "catalog:" }, diff --git a/packages/plugins/graphql/src/api/group.ts b/packages/plugins/graphql/src/api/group.ts index 60f08d1854..7310df91f9 100644 --- a/packages/plugins/graphql/src/api/group.ts +++ b/packages/plugins/graphql/src/api/group.ts @@ -6,12 +6,14 @@ import { GraphqlIntrospectionError, GraphqlExtractionError, } from "../sdk/errors"; +import { StoredSourceSchema } from "../sdk/stored-source"; // --------------------------------------------------------------------------- // Params // --------------------------------------------------------------------------- const scopeIdParam = HttpApiSchema.param("scopeId", ScopeId); +const namespaceParam = HttpApiSchema.param("namespace", Schema.String); // --------------------------------------------------------------------------- // Payloads @@ -26,6 +28,17 @@ const AddSourcePayload = Schema.Struct({ ), }); +const UpdateSourcePayload = Schema.Struct({ + endpoint: Schema.optional(Schema.String), + headers: Schema.optional( + Schema.Record({ key: Schema.String, value: Schema.Unknown }), + ), +}); + +const UpdateSourceResponse = Schema.Struct({ + updated: Schema.Boolean, +}); + // --------------------------------------------------------------------------- // Responses // --------------------------------------------------------------------------- @@ -58,4 +71,13 @@ export class GraphqlGroup extends HttpApiGroup.make("graphql") .addError(IntrospectionError) .addError(ExtractionError), ) + .add( + HttpApiEndpoint.get("getSource")`/scopes/${scopeIdParam}/graphql/sources/${namespaceParam}` + .addSuccess(Schema.NullOr(StoredSourceSchema)), + ) + .add( + HttpApiEndpoint.patch("updateSource")`/scopes/${scopeIdParam}/graphql/sources/${namespaceParam}` + .setPayload(UpdateSourcePayload) + .addSuccess(UpdateSourceResponse), + ) {} diff --git a/packages/plugins/graphql/src/api/handlers.ts b/packages/plugins/graphql/src/api/handlers.ts index 8c3049589c..a4486abe11 100644 --- a/packages/plugins/graphql/src/api/handlers.ts +++ b/packages/plugins/graphql/src/api/handlers.ts @@ -2,7 +2,7 @@ import { HttpApiBuilder } from "@effect/platform"; import { Context, Effect } from "effect"; import { addGroup } from "@executor/api"; -import type { GraphqlPluginExtension, HeaderValue } from "../sdk/plugin"; +import type { GraphqlPluginExtension, HeaderValue, GraphqlUpdateSourceInput } from "../sdk/plugin"; import { GraphqlGroup } from "./group"; // --------------------------------------------------------------------------- @@ -27,19 +27,36 @@ export const GraphqlHandlers = HttpApiBuilder.group( ExecutorApiWithGraphql, "graphql", (handlers) => - handlers.handle("addSource", ({ payload }) => - Effect.gen(function* () { - const ext = yield* GraphqlExtensionService; - const result = yield* ext.addSource({ - endpoint: payload.endpoint, - introspectionJson: payload.introspectionJson, - namespace: payload.namespace, - headers: payload.headers as Record | undefined, - }); - return { - toolCount: result.toolCount, - namespace: payload.namespace ?? "graphql", - }; - }).pipe(Effect.orDie), - ), + handlers + .handle("addSource", ({ payload }) => + Effect.gen(function* () { + const ext = yield* GraphqlExtensionService; + const result = yield* ext.addSource({ + endpoint: payload.endpoint, + introspectionJson: payload.introspectionJson, + namespace: payload.namespace, + headers: payload.headers as Record | undefined, + }); + return { + toolCount: result.toolCount, + namespace: payload.namespace ?? "graphql", + }; + }).pipe(Effect.orDie), + ) + .handle("getSource", ({ path }) => + Effect.gen(function* () { + const ext = yield* GraphqlExtensionService; + return yield* ext.getSource(path.namespace); + }).pipe(Effect.orDie), + ) + .handle("updateSource", ({ path, payload }) => + Effect.gen(function* () { + const ext = yield* GraphqlExtensionService; + yield* ext.updateSource(path.namespace, { + endpoint: payload.endpoint, + headers: payload.headers as Record | undefined, + } as GraphqlUpdateSourceInput); + return { updated: true }; + }).pipe(Effect.orDie), + ), ); diff --git a/packages/plugins/graphql/src/react/EditGraphqlSource.tsx b/packages/plugins/graphql/src/react/EditGraphqlSource.tsx index 901516e974..bf8f872cd9 100644 --- a/packages/plugins/graphql/src/react/EditGraphqlSource.tsx +++ b/packages/plugins/graphql/src/react/EditGraphqlSource.tsx @@ -1,12 +1,153 @@ -export default function EditGraphqlSource(props: { +import { useState } from "react"; +import { useAtomValue, useAtomSet, useAtomRefresh, Result } from "@effect-atom/atom-react"; +import { graphqlSourceAtom, updateGraphqlSource } from "./atoms"; +import { useScope } from "@executor/react/api/scope-context"; +import { useSecretPickerSecrets } from "@executor/react/plugins/use-secret-picker-secrets"; +import { + SecretHeaderAuthRow, + headerValueToState, + headersFromState, + type HeaderState, +} from "@executor/react/plugins/secret-header-auth"; +import { Button } from "@executor/react/components/button"; +import { Input } from "@executor/react/components/input"; +import { Label } from "@executor/react/components/label"; +import { Badge } from "@executor/react/components/badge"; +import type { StoredSourceSchemaType } from "../sdk/stored-source"; + +// --------------------------------------------------------------------------- +// Edit form +// --------------------------------------------------------------------------- + +function EditForm(props: { sourceId: string; + initial: StoredSourceSchemaType; onSave: () => void; }) { + const scopeId = useScope(); + const doUpdate = useAtomSet(updateGraphqlSource, { mode: "promise" }); + const refreshSource = useAtomRefresh(graphqlSourceAtom(scopeId, props.sourceId)); + const secretList = useSecretPickerSecrets(); + + const [endpoint, setEndpoint] = useState(props.initial.config.endpoint); + const [headers, setHeaders] = useState(() => + Object.entries(props.initial.config.headers ?? {}).map(([name, value]) => + headerValueToState(name, value), + ), + ); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [dirty, setDirty] = useState(false); + + const updateHeader = (index: number, update: Partial) => { + setHeaders((prev) => prev.map((h, i) => (i === index ? { ...h, ...update } : h))); + setDirty(true); + }; + + const handleSave = async () => { + setSaving(true); + setError(null); + try { + await doUpdate({ + path: { scopeId, namespace: props.sourceId }, + payload: { + endpoint: endpoint.trim() || undefined, + headers: headersFromState(headers), + }, + }); + refreshSource(); + setDirty(false); + props.onSave(); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to update source"); + } finally { + setSaving(false); + } + }; + return ( -
-

Edit GraphQL Source

-

Source: {props.sourceId}

- +
+
+

Edit GraphQL Source

+

+ Update the endpoint and authentication headers for this source. +

+
+ +
+
+

{props.sourceId}

+
+ GraphQL +
+ +
+ + { setEndpoint((e.target as HTMLInputElement).value); setDirty(true); }} + placeholder="https://api.example.com/graphql" + className="font-mono text-sm" + /> +
+ +
+ + {headers.map((h, i) => ( + updateHeader(i, update)} + onSelectSecret={(secretId) => updateHeader(i, { secretId })} + onRemove={() => { setHeaders((prev) => prev.filter((_, j) => j !== i)); setDirty(true); }} + existingSecrets={secretList} + /> + ))} + +
+ + {error && ( +
+

{error}

+
+ )} + +
+ + +
); } + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- + +export default function EditGraphqlSource(props: { + sourceId: string; + onSave: () => void; +}) { + const scopeId = useScope(); + const sourceResult = useAtomValue(graphqlSourceAtom(scopeId, props.sourceId)); + + if (!Result.isSuccess(sourceResult) || !sourceResult.value) { + return ( +
+
+

Edit GraphQL Source

+

Loading configuration…

+
+
+ ); + } + + return ; +} diff --git a/packages/plugins/graphql/src/react/atoms.ts b/packages/plugins/graphql/src/react/atoms.ts index 6453cbef88..ba54ff2973 100644 --- a/packages/plugins/graphql/src/react/atoms.ts +++ b/packages/plugins/graphql/src/react/atoms.ts @@ -1,7 +1,20 @@ +import type { ScopeId } from "@executor/sdk"; import { GraphqlClient } from "./client"; +// --------------------------------------------------------------------------- +// Query atoms +// --------------------------------------------------------------------------- + +export const graphqlSourceAtom = (scopeId: ScopeId, namespace: string) => + GraphqlClient.query("graphql", "getSource", { + path: { scopeId, namespace }, + timeToLive: "15 seconds", + }); + // --------------------------------------------------------------------------- // Mutation atoms // --------------------------------------------------------------------------- export const addGraphqlSource = GraphqlClient.mutation("graphql", "addSource"); + +export const updateGraphqlSource = GraphqlClient.mutation("graphql", "updateSource"); diff --git a/packages/plugins/graphql/src/sdk/config-file-store.ts b/packages/plugins/graphql/src/sdk/config-file-store.ts new file mode 100644 index 0000000000..64b31ebdc3 --- /dev/null +++ b/packages/plugins/graphql/src/sdk/config-file-store.ts @@ -0,0 +1,69 @@ +/** + * Config-file wrapper for GraphqlOperationStore. + * + * Decorates an underlying store so that `putSource` and `removeSource` also + * write to executor.jsonc. + */ + +import { Effect } from "effect"; +import { FileSystem } from "@effect/platform"; +import type { Layer } from "effect"; + +import { + addSourceToConfig, + removeSourceFromConfig, + SECRET_REF_PREFIX, +} from "@executor/config"; +import type { SourceConfig as ConfigFileSourceConfig, ConfigHeaderValue } from "@executor/config"; + +import type { GraphqlOperationStore, StoredSource } from "./operation-store"; + +type PluginHeaderValue = string | { readonly secretId: string; readonly prefix?: string }; + +const translateSecretHeaders = ( + headers: Readonly> | undefined, +): Record | undefined => { + if (!headers) return undefined; + const result: Record = {}; + for (const [key, value] of Object.entries(headers)) { + if (typeof value === "string") { + result[key] = value; + continue; + } + const ref = `${SECRET_REF_PREFIX}${value.secretId}`; + result[key] = value.prefix ? { value: ref, prefix: value.prefix } : ref; + } + return result; +}; + +const toSourceConfig = (source: StoredSource): ConfigFileSourceConfig => ({ + kind: "graphql", + endpoint: source.config.endpoint, + introspectionJson: source.config.introspectionJson, + namespace: source.namespace, + headers: translateSecretHeaders(source.config.headers), +}); + +export const withConfigFile = ( + inner: GraphqlOperationStore, + configPath: string, + fsLayer: Layer.Layer, +): GraphqlOperationStore => ({ + ...inner, + putSource: (source) => + Effect.gen(function* () { + yield* inner.putSource(source); + yield* addSourceToConfig(configPath, toSourceConfig(source)).pipe( + Effect.provide(fsLayer), + Effect.catchAll(() => Effect.void), + ); + }), + removeSource: (namespace) => + Effect.gen(function* () { + yield* inner.removeSource(namespace); + yield* removeSourceFromConfig(configPath, namespace).pipe( + Effect.provide(fsLayer), + Effect.catchAll(() => Effect.void), + ); + }), +}); diff --git a/packages/plugins/graphql/src/sdk/index.ts b/packages/plugins/graphql/src/sdk/index.ts index 16e98106d6..bf499f79c0 100644 --- a/packages/plugins/graphql/src/sdk/index.ts +++ b/packages/plugins/graphql/src/sdk/index.ts @@ -15,6 +15,7 @@ export { makeKvOperationStore, makeInMemoryOperationStore, } from "./kv-operation-store"; +export { withConfigFile } from "./config-file-store"; export { GraphqlIntrospectionError, diff --git a/packages/plugins/graphql/src/sdk/kv-operation-store.ts b/packages/plugins/graphql/src/sdk/kv-operation-store.ts index 3b8b0f6387..8ef04ee806 100644 --- a/packages/plugins/graphql/src/sdk/kv-operation-store.ts +++ b/packages/plugins/graphql/src/sdk/kv-operation-store.ts @@ -6,7 +6,8 @@ import { Effect, Schema } from "effect"; import { scopeKv, makeInMemoryScopedKv, type Kv, type ToolId, type ScopedKv } from "@executor/sdk"; import type { GraphqlOperationStore, StoredSource } from "./operation-store"; -import { OperationBinding, InvocationConfig, HeaderValue } from "./types"; +import { OperationBinding, InvocationConfig } from "./types"; +import { StoredSourceSchema } from "./stored-source"; // --------------------------------------------------------------------------- // Stored schemas @@ -21,18 +22,6 @@ class StoredEntry extends Schema.Class("StoredEntry")({ const encodeEntry = Schema.encodeSync(Schema.parseJson(StoredEntry)); const decodeEntry = Schema.decodeUnknownSync(Schema.parseJson(StoredEntry)); -const StoredSourceSchema = Schema.Struct({ - namespace: Schema.String, - name: Schema.String, - config: Schema.Struct({ - endpoint: Schema.String, - introspectionJson: Schema.optional(Schema.String), - namespace: Schema.optional(Schema.String), - headers: Schema.optional( - Schema.Record({ key: Schema.String, value: HeaderValue }), - ), - }), -}); const encodeSource = Schema.encodeSync(Schema.parseJson(StoredSourceSchema)); const decodeSource = Schema.decodeUnknownSync(Schema.parseJson(StoredSourceSchema)); @@ -96,6 +85,21 @@ const makeStore = ( const entries = yield* sources.list(); return entries.map((e) => decodeSource(e.value) as StoredSource); }), + + getSource: (namespace) => + Effect.gen(function* () { + const raw = yield* sources.get(namespace); + if (!raw) return null; + return decodeSource(raw) as StoredSource; + }), + + getSourceConfig: (namespace) => + Effect.gen(function* () { + const raw = yield* sources.get(namespace); + if (!raw) return null; + const source = decodeSource(raw) as StoredSource; + return source.config; + }), }); // --------------------------------------------------------------------------- diff --git a/packages/plugins/graphql/src/sdk/operation-store.ts b/packages/plugins/graphql/src/sdk/operation-store.ts index e129b07414..1978d7a31f 100644 --- a/packages/plugins/graphql/src/sdk/operation-store.ts +++ b/packages/plugins/graphql/src/sdk/operation-store.ts @@ -43,4 +43,12 @@ export interface GraphqlOperationStore { readonly removeSource: (namespace: string) => Effect.Effect; readonly listSources: () => Effect.Effect; + + readonly getSource: ( + namespace: string, + ) => Effect.Effect; + + readonly getSourceConfig: ( + namespace: string, + ) => Effect.Effect; } diff --git a/packages/plugins/graphql/src/sdk/plugin.ts b/packages/plugins/graphql/src/sdk/plugin.ts index 4b38f163fb..2289c47d6f 100644 --- a/packages/plugins/graphql/src/sdk/plugin.ts +++ b/packages/plugins/graphql/src/sdk/plugin.ts @@ -21,7 +21,7 @@ import { GraphqlExtractionError, } from "./errors"; import { makeGraphqlInvoker } from "./invoke"; -import type { GraphqlOperationStore } from "./operation-store"; +import type { GraphqlOperationStore, StoredSource } from "./operation-store"; import { makeInMemoryOperationStore } from "./kv-operation-store"; import { ExtractedField, @@ -53,6 +53,11 @@ export interface GraphqlSourceConfig { // Plugin extension // --------------------------------------------------------------------------- +export interface GraphqlUpdateSourceInput { + readonly endpoint?: string; + readonly headers?: Record; +} + export interface GraphqlPluginExtension { /** Add a GraphQL endpoint and register its operations as tools */ readonly addSource: ( @@ -61,6 +66,17 @@ export interface GraphqlPluginExtension { /** Remove all tools from a previously added GraphQL source by namespace */ readonly removeSource: (namespace: string) => Effect.Effect; + + /** Fetch the full stored source by namespace (or null if missing) */ + readonly getSource: ( + namespace: string, + ) => Effect.Effect; + + /** Update config (endpoint, headers) for an existing GraphQL source */ + readonly updateSource: ( + namespace: string, + input: GraphqlUpdateSourceInput, + ) => Effect.Effect; } // --------------------------------------------------------------------------- @@ -226,6 +242,7 @@ export const graphqlPlugin = (options?: { runtime: false, canRemove: true, canRefresh: false, + canEdit: true, }), ), ), @@ -421,6 +438,43 @@ export const graphqlPlugin = (options?: { } yield* operationStore.removeSource(namespace); }), + + getSource: (namespace: string) => + operationStore.getSource(namespace), + + updateSource: (namespace: string, input: GraphqlUpdateSourceInput) => + Effect.gen(function* () { + const existingConfig = yield* operationStore.getSourceConfig(namespace); + if (!existingConfig) return; + + const updatedConfig = { + ...existingConfig, + ...(input.endpoint !== undefined ? { endpoint: input.endpoint } : {}), + ...(input.headers !== undefined ? { headers: input.headers as Record } : {}), + }; + + const newInvocationConfig = new InvocationConfig({ + endpoint: updatedConfig.endpoint, + headers: (updatedConfig.headers ?? {}) as Record, + }); + + const toolIds = yield* operationStore.listByNamespace(namespace); + for (const toolId of toolIds) { + const entry = yield* operationStore.get(toolId); + if (entry) { + yield* operationStore.put(toolId, namespace, entry.binding, newInvocationConfig); + } + } + + const sources = yield* operationStore.listSources(); + const existingMeta = sources.find((s) => s.namespace === namespace); + + yield* operationStore.putSource({ + namespace, + name: existingMeta?.name ?? namespace, + config: updatedConfig, + }); + }), }, close: () => runtimeTools.close(), diff --git a/packages/plugins/graphql/src/sdk/stored-source.ts b/packages/plugins/graphql/src/sdk/stored-source.ts new file mode 100644 index 0000000000..56e4e0e5ff --- /dev/null +++ b/packages/plugins/graphql/src/sdk/stored-source.ts @@ -0,0 +1,25 @@ +import { Schema } from "effect"; + +import { HeaderValue } from "./types"; + +// --------------------------------------------------------------------------- +// Stored source — the shape persisted by the operation store and exposed +// via the getSource HTTP endpoint. +// --------------------------------------------------------------------------- + +export class StoredSourceSchema extends Schema.Class( + "GraphqlStoredSource", +)({ + namespace: Schema.String, + name: Schema.String, + config: Schema.Struct({ + endpoint: Schema.String, + introspectionJson: Schema.optional(Schema.String), + namespace: Schema.optional(Schema.String), + headers: Schema.optional( + Schema.Record({ key: Schema.String, value: HeaderValue }), + ), + }), +}) {} + +export type StoredSourceSchemaType = typeof StoredSourceSchema.Type;