History

feat: end-to-end source add flow + remove plugin-specific list/remove

- Wire OpenAPI plugin into server (executor config + API handlers) - AddOpenApiSource calls previewSpec + addSpec APIs through the flow - Sources page uses core toolsAtom() grouped by namespace tag - Remove listSpecs/removeSpec from OpenAPI API (core tools handles it) - Remove listNamespaces from operation store, listSpecs from extension - Sources page refreshes tool list on add complete

Rhys Sullivan committed 7 months ago

Commit 2d91da11 parent 4a39f4f16 files · +327 −101Browse files

Unified · Split

16 files
  1. .gitignore +2 −1
  2. apps/server/package.json +1 −0
  3. apps/server/src/handlers/openapi.ts +17 −0
  4. apps/server/src/main.ts +11 −3
  5. apps/server/src/services/executor.ts +12 −3
  6. apps/web/package.json +1 −0
  7. apps/web/src/pages/sources.tsx +85 −1
  8. bun.lock +4 −0
  9. packages/plugins/openapi/package.json +2 −0
  10. packages/plugins/openapi/src/api/group.ts +0 −17
  11. packages/plugins/openapi/src/api/handlers.ts +1 −11
  12. packages/plugins/openapi/src/react/AddOpenApiSource.tsx +187 −40
  13. packages/plugins/openapi/src/react/atoms.ts +0 −23
  14. packages/plugins/openapi/src/react/index.ts +0 −2
  15. packages/plugins/openapi/src/sdk/operation-store.ts +2 −0
  16. packages/plugins/openapi/src/sdk/plugin.ts +2 −0
.gitignore+2−1
@@ -32,4 +32,5 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json · 31 unmodified lines
3232
3333 # Finder (MacOS) folder config
3434 .DS_Store
35−personal-notes/
35+personal-notes/
36+*.har
apps/server/package.json+1−0
@@ -10,6 +10,7 @@ · 9 unmodified lines
1010 "@effect/platform": "catalog:",
1111
1212 "@executor/api": "workspace:*",
13+ "@executor/plugin-openapi": "workspace:*",
1314 "@executor/sdk": "workspace:*",
1415 "effect": "catalog:"
1516 },
apps/server/src/handlers/openapi.ts+17−0
@@ -0,0 +1,17 @@
1+import { Effect, Layer } from "effect";
2+import {
3+ OpenApiHandlers,
4+ OpenApiExtensionService,
5+} from "@executor/plugin-openapi/api";
6+import { ExecutorService } from "../services/executor";
7+
8+// Wire OpenApiExtensionService from the executor's openapi extension
9+const OpenApiExtensionLive = Layer.effect(
10+ OpenApiExtensionService,
11+ Effect.map(ExecutorService, (executor) => executor.openapi),
12+);
13+
14+export const OpenApiHandlersLive = Layer.provide(
15+ OpenApiHandlers,
16+ OpenApiExtensionLive,
17+);
apps/server/src/main.ts+11−3
@@ -6,17 +6,25 @@ import { · 5 unmodified lines
66 } from "@effect/platform";
77 import { Layer } from "effect";
88
9−import { ExecutorApi } from "@executor/api";
9+import { addGroup } from "@executor/api";
10+import { OpenApiGroup } from "@executor/plugin-openapi/api";
1011 import { ToolsHandlers } from "./handlers/tools";
1112 import { SecretsHandlers } from "./handlers/secrets";
13+import { OpenApiHandlersLive } from "./handlers/openapi";
1214 import { ExecutorServiceLive } from "./services/executor";
1315
16+// ---------------------------------------------------------------------------
17+// Composed API — core + plugin groups
18+// ---------------------------------------------------------------------------
19+
20+const ExecutorApiWithPlugins = addGroup(OpenApiGroup);
21+
1422 // ---------------------------------------------------------------------------
1523 // API layer — wire handlers
1624 // ---------------------------------------------------------------------------
1725
18−const ApiLive = HttpApiBuilder.api(ExecutorApi).pipe(
19− Layer.provide([ToolsHandlers, SecretsHandlers]),
26+const ApiLive = HttpApiBuilder.api(ExecutorApiWithPlugins).pipe(
27+ Layer.provide([ToolsHandlers, SecretsHandlers, OpenApiHandlersLive]),
2028 Layer.provide(ExecutorServiceLive),
2129 );
2230
apps/server/src/services/executor.ts+12−3
@@ -1,7 +1,12 @@
11 import { Context, Effect, Layer } from "effect";
22 import { createExecutor, makeTestConfig } from "@executor/sdk";
3+import { openApiPlugin, type OpenApiPluginExtension } from "@executor/plugin-openapi";
34
45 import type { Executor } from "@executor/sdk";
6+import type { ExecutorPlugin } from "@executor/sdk";
7+
8+type ServerPlugins = readonly [ExecutorPlugin<"openapi", OpenApiPluginExtension>];
9+type ServerExecutor = Executor<ServerPlugins>;
510
611 // ---------------------------------------------------------------------------
712 // Service tag — provides the executor instance to HTTP handlers
@@ -9,14 +14,18 @@ import type { Executor } from "@executor/sdk"; · 1 unmodified line
914
1015 export class ExecutorService extends Context.Tag("ExecutorService")<
1116 ExecutorService,
12− Executor
17+ ServerExecutor
1318 >() {}
1419
1520 // ---------------------------------------------------------------------------
16−// Default layer — creates an in-memory executor
21+// Default layer — creates an in-memory executor with plugins
1722 // ---------------------------------------------------------------------------
1823
1924 export const ExecutorServiceLive = Layer.effect(
2025 ExecutorService,
21− createExecutor(makeTestConfig()),
26+ createExecutor(
27+ makeTestConfig({
28+ plugins: [openApiPlugin()] as const,
29+ }),
30+ ),
2231 );
apps/web/package.json+1−0
@@ -4,6 +4,7 @@ · 3 unmodified lines
44 "private": true,
55 "type": "module",
66 "dependencies": {
7+ "@executor/plugin-openapi": "workspace:*",
78 "@executor/react": "workspace:*",
89 "@executor/server": "workspace:*",
910 "@tanstack/react-router": "^1.168.10",
apps/web/src/pages/sources.tsx+85−1
@@ -1,8 +1,92 @@
1+import { useState, Suspense, useMemo } from "react";
2+import { Result, useAtomValue, useAtomRefresh, toolsAtom } from "@executor/react";
3+import type { SourcePlugin } from "@executor/react";
4+import { openApiSourcePlugin } from "@executor/plugin-openapi/react";
5+
6+// ---------------------------------------------------------------------------
7+// Registered source plugins
8+// ---------------------------------------------------------------------------
9+
10+const sourcePlugins: SourcePlugin[] = [openApiSourcePlugin];
11+
12+// ---------------------------------------------------------------------------
13+// Page
14+// ---------------------------------------------------------------------------
15+
116 export function SourcesPage() {
17+ const [adding, setAdding] = useState<string | null>(null);
18+ const tools = useAtomValue(toolsAtom());
19+ const refreshTools = useAtomRefresh(toolsAtom());
20+
21+ // Group tools by namespace (second tag after "openapi")
22+ const sources = useMemo(() => {
23+ if (tools._tag !== "Success") return [];
24+ const namespaces = new Map<string, number>();
25+ for (const tool of tools.value) {
26+ // Tools are tagged ["openapi", namespace, ...]
27+ const ns = tool.tags.find(
28+ (t) => t !== "openapi" && !sourcePlugins.some((p) => p.key === t),
29+ );
30+ if (ns) {
31+ namespaces.set(ns, (namespaces.get(ns) ?? 0) + 1);
32+ }
33+ }
34+ return [...namespaces.entries()].map(([namespace, toolCount]) => ({
35+ namespace,
36+ toolCount,
37+ }));
38+ }, [tools]);
39+
40+ const plugin = adding
41+ ? sourcePlugins.find((p) => p.key === adding)
42+ : undefined;
43+
44+ if (plugin) {
45+ const AddComponent = plugin.add;
46+ return (
47+ <Suspense fallback={<p>Loading…</p>}>
48+ <AddComponent
49+ onComplete={() => {
50+ setAdding(null);
51+ refreshTools();
52+ }}
53+ onCancel={() => setAdding(null)}
54+ />
55+ </Suspense>
56+ );
57+ }
58+
259 return (
360 <div>
461 <h2>Sources</h2>
5− <p style={{ color: "#888" }}>No sources configured yet.</p>
62+
63+ {Result.match(tools, {
64+ onInitial: () => <p>Loading…</p>,
65+ onFailure: () => <p style={{ color: "red" }}>Failed to load sources</p>,
66+ onSuccess: () =>
67+ sources.length === 0 ? (
68+ <p style={{ color: "#888" }}>No sources configured yet.</p>
69+ ) : (
70+ <ul>
71+ {sources.map((s) => (
72+ <li key={s.namespace}>
73+ <strong>{s.namespace}</strong> — {s.toolCount} tools
74+ </li>
75+ ))}
76+ </ul>
77+ ),
78+ })}
79+
80+ <div style={{ marginTop: "1rem" }}>
81+ <h3>Add a source</h3>
82+ <div style={{ display: "flex", gap: "0.5rem" }}>
83+ {sourcePlugins.map((p) => (
84+ <button key={p.key} onClick={() => setAdding(p.key)}>
85+ {p.label}
86+ </button>
87+ ))}
88+ </div>
89+ </div>
690 </div>
791 );
892 }
bun.lock+4−0
@@ -17,6 +17,7 @@ · 16 unmodified lines
1717 "dependencies": {
1818 "@effect/platform": "catalog:",
1919 "@executor/api": "workspace:*",
20+ "@executor/plugin-openapi": "workspace:*",
2021 "@executor/sdk": "workspace:*",
2122 "effect": "catalog:",
2223 },
@@ -30,6 +31,7 @@ · 7 unmodified lines
3031 "name": "@executor/web",
3132 "version": "0.0.0",
3233 "dependencies": {
34+ "@executor/plugin-openapi": "workspace:*",
3335 "@executor/react": "workspace:*",
3436 "@executor/server": "workspace:*",
3537 "@tanstack/react-router": "^1.168.10",
@@ -175,12 +177,14 @@ · 139 unmodified lines
175177 "@effect-atom/atom-react": "^0.5.0",
176178 "@executor/api": "workspace:*",
177179 "@executor/react": "workspace:*",
180+ "@tanstack/react-router": "^1.168.10",
178181 "react": "^19.1.0",
179182 },
180183 "optionalPeers": [
181184 "@effect-atom/atom-react",
182185 "@executor/api",
183186 "@executor/react",
187+ "@tanstack/react-router",
184188 "react",
185189 ],
186190 },
packages/plugins/openapi/package.json+2−0
@@ -25,12 +25,14 @@ · 24 unmodified lines
2525 "peerDependencies": {
2626 "react": "^19.1.0",
2727 "@effect-atom/atom-react": "^0.5.0",
28+ "@tanstack/react-router": "^1.168.10",
2829 "@executor/api": "workspace:*",
2930 "@executor/react": "workspace:*"
3031 },
3132 "peerDependenciesMeta": {
3233 "react": { "optional": true },
3334 "@effect-atom/atom-react": { "optional": true },
35+ "@tanstack/react-router": { "optional": true },
3436 "@executor/api": { "optional": true },
3537 "@executor/react": { "optional": true }
3638 },
packages/plugins/openapi/src/api/group.ts+0−17
@@ -40,13 +40,6 @@ const AddSpecResponse = Schema.Struct({ · 39 unmodified lines
4040 namespace: Schema.String,
4141 });
4242
43−const ListSpecsResponse = Schema.Array(
44− Schema.Struct({
45− namespace: Schema.String,
46− toolCount: Schema.Number,
47− }),
48−);
49−
5043 // ---------------------------------------------------------------------------
5144 // Errors with HTTP status
5245 // ---------------------------------------------------------------------------
@@ -62,8 +55,6 @@ const ExtractionError = OpenApiExtractionError.annotations( · 9 unmodified lines
6255 // Group
6356 // ---------------------------------------------------------------------------
6457
65−const namespaceParam = HttpApiSchema.param("namespace", Schema.String);
66−
6758 export class OpenApiGroup extends HttpApiGroup.make("openapi")
6859 .add(
6960 HttpApiEndpoint.post("previewSpec")`/scopes/${scopeIdParam}/openapi/preview`
@@ -79,12 +70,4 @@ export class OpenApiGroup extends HttpApiGroup.make("openapi") · 9 unmodified lines
7970 .addError(ParseError)
8071 .addError(ExtractionError),
8172 )
82− .add(
83− HttpApiEndpoint.get("listSpecs")`/scopes/${scopeIdParam}/openapi/specs`
84− .addSuccess(ListSpecsResponse),
85− )
86− .add(
87− HttpApiEndpoint.del("removeSpec")`/scopes/${scopeIdParam}/openapi/specs/${namespaceParam}`
88− .addSuccess(Schema.Struct({ removed: Schema.Boolean })),
89− )
9073 .prefix("/v1") {}
packages/plugins/openapi/src/api/handlers.ts+1−11
@@ -49,15 +49,5 @@ export const OpenApiHandlers = HttpApiBuilder.group( · 48 unmodified lines
4949 };
5050 }).pipe(Effect.orDie),
5151 )
52− .handle("listSpecs", () =>
53− // TODO: wire to operation store listByNamespace
54− Effect.succeed([] as { namespace: string; toolCount: number }[]),
55− )
56− .handle("removeSpec", ({ path }) =>
57− Effect.gen(function* () {
58− const ext = yield* OpenApiExtensionService;
59− yield* ext.removeSpec(path.namespace);
60− return { removed: true };
61− }),
62− ),
52+ ,
6353 );
packages/plugins/openapi/src/react/AddOpenApiSource.tsx+187−40
@@ -1,55 +1,202 @@
1−import { useState } from "react";
1+import { useState, useMemo, createContext, useContext } from "react";
2+import {
3+ createMemoryHistory,
4+ createRootRoute,
5+ createRoute,
6+ createRouter,
7+ Outlet,
8+ RouterProvider,
9+} from "@tanstack/react-router";
10+import { useAtomSet, Result } from "@effect-atom/atom-react";
11+import { Option } from "effect";
212
3−export default function AddOpenApiSource(props: {
13+import { previewOpenApiSpec, addOpenApiSpec } from "./atoms";
14+import type { SpecPreview } from "../sdk/preview";
15+
16+// ---------------------------------------------------------------------------
17+// Shared state via context
18+// ---------------------------------------------------------------------------
19+
20+type Navigate = (to: string) => void;
21+const NavContext = createContext<Navigate>(() => {});
22+const useNav = () => useContext(NavContext);
23+
24+interface AddState {
25+ specUrl: string;
26+ setSpecUrl: (v: string) => void;
27+ preview: Result.Result<SpecPreview, unknown> | null;
28+ doPreview: (spec: string) => void;
29+ doAdd: (spec: string, baseUrl?: string, headers?: Record<string, unknown>) => void;
430 onComplete: () => void;
531 onCancel: () => void;
6−}) {
7− const [step, setStep] = useState<"url" | "auth" | "confirm">("url");
8− const [specUrl, setSpecUrl] = useState("");
32+}
33+
34+const AddStateContext = createContext<AddState>(null!);
35+const useAddState = () => useContext(AddStateContext);
36+
37+// ---------------------------------------------------------------------------
38+// Steps
39+// ---------------------------------------------------------------------------
40+
41+function UrlStep() {
42+ const { specUrl, setSpecUrl, onCancel, doPreview } = useAddState();
43+ const nav = useNav();
44+
45+ const handleNext = () => {
46+ doPreview(specUrl);
47+ nav("/auth");
48+ };
949
1050 return (
1151 <div>
12− <h3>Add OpenAPI Source</h3>
52+ <label>
53+ Spec URL or paste JSON/YAML:
54+ <textarea
55+ value={specUrl}
56+ onChange={(e) => setSpecUrl((e.target as HTMLTextAreaElement).value)}
57+ placeholder={'https://api.example.com/openapi.json\n\nor paste spec content here...'}
58+ rows={6}
59+ style={{ width: "100%", fontFamily: "monospace", fontSize: "0.85rem" }}
60+ />
61+ </label>
62+ <div style={{ marginTop: "0.5rem", display: "flex", gap: "0.5rem" }}>
63+ <button onClick={onCancel}>Cancel</button>
64+ <button disabled={!specUrl.trim()} onClick={handleNext}>
65+ Next
66+ </button>
67+ </div>
68+ </div>
69+ );
70+}
1371
14− {step === "url" && (
15− <div>
16− <label>
17− Spec URL or paste content:
18− <input
19− type="text"
20− value={specUrl}
21− onChange={(e) => setSpecUrl((e.target as HTMLInputElement).value)}
22− placeholder="https://api.example.com/openapi.json"
23− />
24− </label>
25− <div>
26− <button onClick={props.onCancel}>Cancel</button>
27− <button onClick={() => setStep("auth")} disabled={!specUrl}>
28− Next
29− </button>
30− </div>
31− </div>
32− )}
72+function AuthStep() {
73+ const { preview } = useAddState();
74+ const nav = useNav();
3375
34− {step === "auth" && (
35− <div>
36− <p>Configure authentication headers…</p>
76+ return (
77+ <div>
78+ {preview && Result.match(preview, {
79+ onInitial: () => <p>Analyzing spec…</p>,
80+ onSuccess: ({ value }) => (
3781 <div>
38− <button onClick={() => setStep("url")}>Back</button>
39− <button onClick={() => setStep("confirm")}>Next</button>
82+ <h4>{Option.getOrElse(value.title, () => "API")} {Option.getOrElse(value.version, () => "")}</h4>
83+ <p>{value.operationCount} operations found</p>
84+ {value.headerPresets.length > 0 && (
85+ <div>
86+ <p>Authentication options:</p>
87+ <ul>
88+ {value.headerPresets.map((p, i) => (
89+ <li key={i}>{p.label}</li>
90+ ))}
91+ </ul>
92+ </div>
93+ )}
4094 </div>
41− </div>
42− )}
95+ ),
96+ onFailure: () => <p style={{ color: "red" }}>Failed to parse spec</p>,
97+ })}
98+ <div style={{ marginTop: "0.5rem", display: "flex", gap: "0.5rem" }}>
99+ <button onClick={() => nav("/")}>Back</button>
100+ <button onClick={() => nav("/confirm")}>Next</button>
101+ </div>
102+ </div>
103+ );
104+}
43105
44− {step === "confirm" && (
106+function ConfirmStep() {
107+ const { specUrl, doAdd, onComplete } = useAddState();
108+ const [adding, setAdding] = useState(false);
109+ const [error, setError] = useState<string | null>(null);
110+ const nav = useNav();
111+
112+ const handleAdd = async () => {
113+ setAdding(true);
114+ setError(null);
115+ try {
116+ doAdd(specUrl);
117+ onComplete();
118+ } catch (e) {
119+ setError(e instanceof Error ? e.message : String(e));
120+ setAdding(false);
121+ }
122+ };
123+
124+ return (
125+ <div>
126+ <p>Ready to add spec.</p>
127+ {error && <p style={{ color: "red" }}>{error}</p>}
128+ <div style={{ marginTop: "0.5rem", display: "flex", gap: "0.5rem" }}>
129+ <button onClick={() => nav("/auth")} disabled={adding}>Back</button>
130+ <button onClick={handleAdd} disabled={adding}>
131+ {adding ? "Adding…" : "Add Source"}
132+ </button>
133+ </div>
134+ </div>
135+ );
136+}
137+
138+// ---------------------------------------------------------------------------
139+// Component
140+// ---------------------------------------------------------------------------
141+
142+export default function AddOpenApiSource(props: {
143+ onComplete: () => void;
144+ onCancel: () => void;
145+}) {
146+ const [specUrl, setSpecUrl] = useState("");
147+ const [preview] = useState<Result.Result<SpecPreview, unknown> | null>(null);
148+
149+ const doPreviewMutation = useAtomSet(previewOpenApiSpec, { mode: "promise" });
150+ const doAddMutation = useAtomSet(addOpenApiSpec, { mode: "promise" });
151+
152+ const router = useMemo(() => {
153+ const rootRoute = createRootRoute({
154+ component: () => (
45155 <div>
46− <p>Ready to add spec from: {specUrl}</p>
47− <div>
48− <button onClick={() => setStep("auth")}>Back</button>
49− <button onClick={props.onComplete}>Add Source</button>
50− </div>
156+ <h3>Add OpenAPI Source</h3>
157+ <Outlet />
51158 </div>
52− )}
53− </div>
159+ ),
160+ });
161+
162+ const routeTree = rootRoute.addChildren([
163+ createRoute({ getParentRoute: () => rootRoute, path: "/", component: UrlStep }),
164+ createRoute({ getParentRoute: () => rootRoute, path: "/auth", component: AuthStep }),
165+ createRoute({ getParentRoute: () => rootRoute, path: "/confirm", component: ConfirmStep }),
166+ ]);
167+
168+ return createRouter({
169+ routeTree,
170+ history: createMemoryHistory({ initialEntries: ["/"] }),
171+ });
172+ }, []);
173+
174+ const navigate: Navigate = (to) => {
175+ void router.navigate({ to });
176+ };
177+
178+ const state: AddState = {
179+ specUrl,
180+ setSpecUrl,
181+ preview,
182+ doPreview: (spec) => {
183+ void doPreviewMutation({ path: { scopeId: "default" as never }, payload: { spec } });
184+ },
185+ doAdd: (spec, baseUrl, headers) => {
186+ void doAddMutation({
187+ path: { scopeId: "default" as never },
188+ payload: { spec, baseUrl, headers },
189+ });
190+ },
191+ onComplete: props.onComplete,
192+ onCancel: props.onCancel,
193+ };
194+
195+ return (
196+ <AddStateContext.Provider value={state}>
197+ <NavContext.Provider value={navigate}>
198+ <RouterProvider router={router} />
199+ </NavContext.Provider>
200+ </AddStateContext.Provider>
54201 );
55202 }
packages/plugins/openapi/src/react/atoms.ts+0−23
@@ -1,23 +1,5 @@
1−import { ScopeId } from "@executor/sdk";
2−
31 import { OpenApiClient } from "./client";
42
5−// ---------------------------------------------------------------------------
6−// Default scope
7−// ---------------------------------------------------------------------------
8−
9−const DEFAULT_SCOPE = ScopeId.make("default");
10−
11−// ---------------------------------------------------------------------------
12−// Query atoms
13−// ---------------------------------------------------------------------------
14−
15−export const openApiSpecsAtom = (scopeId: ScopeId = DEFAULT_SCOPE) =>
16− OpenApiClient.query("openapi", "listSpecs", {
17− path: { scopeId },
18− timeToLive: "30 seconds",
19− });
20−
213 // ---------------------------------------------------------------------------
224 // Mutation atoms
235 // ---------------------------------------------------------------------------
@@ -28,8 +10,3 @@ export const previewOpenApiSpec = OpenApiClient.mutation( · 4 unmodified lines
2810 );
2911
3012 export const addOpenApiSpec = OpenApiClient.mutation("openapi", "addSpec");
31−
32−export const removeOpenApiSpec = OpenApiClient.mutation(
33− "openapi",
34− "removeSpec",
35−);
packages/plugins/openapi/src/react/index.ts+0−2
@@ -1,8 +1,6 @@
11 export { openApiSourcePlugin } from "./source-plugin";
22 export { OpenApiClient } from "./client";
33 export {
4− openApiSpecsAtom,
54 previewOpenApiSpec,
65 addOpenApiSpec,
7− removeOpenApiSpec,
86 } from "./atoms";
packages/plugins/openapi/src/sdk/operation-store.ts+2−0
@@ -26,6 +26,7 @@ export interface OpenApiOperationStore { · 25 unmodified lines
2626
2727 /** Remove all entries for a namespace */
2828 readonly removeByNamespace: (namespace: string) => Effect.Effect<readonly ToolId[]>;
29+
2930 }
3031
3132 // ---------------------------------------------------------------------------
@@ -73,5 +74,6 @@ export const makeInMemoryOperationStore = (): OpenApiOperationStore => { · 41 unmodified lines
7374 }
7475 return ids;
7576 }),
77+
7678 };
7779 };
packages/plugins/openapi/src/sdk/plugin.ts+2−0
@@ -57,6 +57,7 @@ export interface OpenApiPluginExtension { · 56 unmodified lines
5757
5858 /** Remove all tools from a previously added spec by namespace */
5959 readonly removeSpec: (namespace: string) => Effect.Effect<void>;
60+
6061 }
6162
6263 // ---------------------------------------------------------------------------
@@ -168,6 +169,7 @@ export const openApiPlugin = (options?: { · 105 unmodified lines
168169 yield* ctx.tools.unregister(toolIds);
169170 }
170171 }),
172+
171173 },
172174
173175 close: () =>