Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/plugins/graphql/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"dependencies": {
"@effect/platform": "catalog:",
"@effect/platform-node": "catalog:",
"@executor/config": "workspace:*",
"@executor/sdk": "workspace:*",
"effect": "catalog:"
},
Expand Down
22 changes: 22 additions & 0 deletions packages/plugins/graphql/src/api/group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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),
)
{}
49 changes: 33 additions & 16 deletions packages/plugins/graphql/src/api/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

// ---------------------------------------------------------------------------
Expand All @@ -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<string, HeaderValue> | 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<string, HeaderValue> | 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<string, HeaderValue> | undefined,
} as GraphqlUpdateSourceInput);
return { updated: true };
}).pipe(Effect.orDie),
),
);
151 changes: 146 additions & 5 deletions packages/plugins/graphql/src/react/EditGraphqlSource.tsx
Original file line number Diff line number Diff line change
@@ -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<HeaderState[]>(() =>
Object.entries(props.initial.config.headers ?? {}).map(([name, value]) =>
headerValueToState(name, value),
),
);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [dirty, setDirty] = useState(false);

const updateHeader = (index: number, update: Partial<HeaderState>) => {
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 (
<div>
<h3>Edit GraphQL Source</h3>
<p>Source: {props.sourceId}</p>
<button onClick={props.onSave}>Save</button>
<div className="space-y-6">
<div>
<h1 className="text-xl font-semibold text-foreground">Edit GraphQL Source</h1>
<p className="mt-1 text-[13px] text-muted-foreground">
Update the endpoint and authentication headers for this source.
</p>
</div>

<div className="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-card-foreground">{props.sourceId}</p>
</div>
<Badge variant="secondary" className="text-[10px]">GraphQL</Badge>
</div>

<section className="space-y-2">
<Label>Endpoint</Label>
<Input
value={endpoint}
onChange={(e) => { setEndpoint((e.target as HTMLInputElement).value); setDirty(true); }}
placeholder="https://api.example.com/graphql"
className="font-mono text-sm"
/>
</section>

<section className="space-y-2.5">
<Label>Headers</Label>
{headers.map((h, i) => (
<SecretHeaderAuthRow
key={i}
name={h.name}
prefix={h.prefix}
presetKey={h.presetKey}
secretId={h.secretId}
onChange={(update) => updateHeader(i, update)}
onSelectSecret={(secretId) => updateHeader(i, { secretId })}
onRemove={() => { setHeaders((prev) => prev.filter((_, j) => j !== i)); setDirty(true); }}
existingSecrets={secretList}
/>
))}
<Button variant="outline" size="sm" className="w-full border-dashed" onClick={() => { setHeaders((prev) => [...prev, { name: "", secretId: null }]); setDirty(true); }}>
+ Add header
</Button>
</section>

{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2">
<p className="text-[12px] text-destructive">{error}</p>
</div>
)}

<div className="flex items-center justify-between border-t border-border pt-4">
<Button variant="ghost" onClick={props.onSave}>Cancel</Button>
<Button onClick={handleSave} disabled={!dirty || saving}>
{saving ? "Saving…" : "Save changes"}
</Button>
</div>
</div>
);
}

// ---------------------------------------------------------------------------
// 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 (
<div className="space-y-6">
<div>
<h1 className="text-xl font-semibold text-foreground">Edit GraphQL Source</h1>
<p className="mt-1 text-[13px] text-muted-foreground">Loading configuration…</p>
</div>
</div>
);
}

return <EditForm sourceId={props.sourceId} initial={sourceResult.value} onSave={props.onSave} />;
}
13 changes: 13 additions & 0 deletions packages/plugins/graphql/src/react/atoms.ts
Original file line number Diff line number Diff line change
@@ -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");
69 changes: 69 additions & 0 deletions packages/plugins/graphql/src/sdk/config-file-store.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, PluginHeaderValue>> | undefined,
): Record<string, ConfigHeaderValue> | undefined => {
if (!headers) return undefined;
const result: Record<string, ConfigHeaderValue> = {};
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<FileSystem.FileSystem>,
): 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),
);
}),
});
1 change: 1 addition & 0 deletions packages/plugins/graphql/src/sdk/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export {
makeKvOperationStore,
makeInMemoryOperationStore,
} from "./kv-operation-store";
export { withConfigFile } from "./config-file-store";

export {
GraphqlIntrospectionError,
Expand Down
Loading