History

feat: plugin API routes + frontend component registration

OpenAPI plugin now has three sub-path exports: - @executor/plugin-openapi (SDK plugin, unchanged) - @executor/plugin-openapi/api (HTTP routes + handlers) - OpenApiGroup: previewSpec, addSpec, listSpecs, removeSpec endpoints - OpenApiHandlers: Effect HttpApiBuilder handlers - OpenApiExtensionService: Context tag for wiring - @executor/plugin-openapi/react (React components + atoms) - OpenApiClient: typed AtomHttpApi client (core + openapi routes) - Atoms: openApiSpecsAtom, previewOpenApiSpec, addOpenApiSpec, removeOpenApiSpec - openApiSourcePlugin: implements SourcePlugin contract - Lazy-loaded components: AddOpenApiSource (multi-step), EditOpenApiSource, OpenApiSourceSummary Shared infrastructure: - @executor/api: addGroup() composes plugin HttpApiGroups with CoreExecutorApi - @executor/react: SourcePlugin contract type for shell↔plugin UI composition Shell owns layout/nav/common actions, plugin owns content within typed frames Pattern: plugins use addGroup() + AtomHttpApi.Tag to get typed query/mutation atoms for both core and plugin-specific routes. Peer deps with optional metadata so SDK-only consumers don't pull in React. 10 packages typecheck clean, 21 OpenAPI tests passing.

Rhys Sullivan committed 7 months ago

Commit 1ddae2a1 parent f973e3218 files · +416 −6Browse files

Unified · Split

18 files
  1. bun.lock +17 −0
  2. packages/clients/react/src/client.ts +1 −1
  3. packages/clients/react/src/index.ts +3 −0
  4. packages/clients/react/src/source-plugin.ts +49 −0
  5. packages/core/api/src/api.ts +12 −1
  6. packages/core/api/src/index.ts +1 −1
  7. packages/plugins/openapi/package.json +20 −1
  8. packages/plugins/openapi/src/api/group.ts +90 −0
  9. packages/plugins/openapi/src/api/handlers.ts +63 −0
  10. packages/plugins/openapi/src/api/index.ts +2 −0
  11. packages/plugins/openapi/src/react/AddOpenApiSource.tsx +55 −0
  12. packages/plugins/openapi/src/react/EditOpenApiSource.tsx +13 −0
  13. packages/plugins/openapi/src/react/OpenApiSourceSummary.tsx +9 −0
  14. packages/plugins/openapi/src/react/atoms.ts +35 −0
  15. packages/plugins/openapi/src/react/client.ts +20 −0
  16. packages/plugins/openapi/src/react/index.ts +8 −0
  17. packages/plugins/openapi/src/react/source-plugin.ts +14 −0
  18. packages/plugins/openapi/tsconfig.json +4 −2
bun.lock+17−0
@@ -160,11 +160,28 @@ · 159 unmodified lines
160160 "yaml": "^2.7.1",
161161 },
162162 "devDependencies": {
163+ "@effect-atom/atom-react": "^0.5.0",
163164 "@effect/vitest": "catalog:",
165+ "@executor/api": "workspace:*",
166+ "@executor/react": "workspace:*",
164167 "@types/node": "catalog:",
168+ "@types/react": "^19.1.0",
165169 "bun-types": "catalog:",
170+ "react": "^19.1.0",
166171 "vitest": "catalog:",
167172 },
173+ "peerDependencies": {
174+ "@effect-atom/atom-react": "^0.5.0",
175+ "@executor/api": "workspace:*",
176+ "@executor/react": "workspace:*",
177+ "react": "^19.1.0",
178+ },
179+ "optionalPeers": [
180+ "@effect-atom/atom-react",
181+ "@executor/api",
182+ "@executor/react",
183+ "react",
184+ ],
168185 },
169186 },
170187 "catalog": {
packages/clients/react/src/client.ts+1−1
@@ -5,7 +5,7 @@ import { ExecutorApi } from "@executor/api"; · 4 unmodified lines
55 import { getBaseUrl } from "./base-url";
66
77 // ---------------------------------------------------------------------------
8−// Typed HTTP API client — cached per base URL
8+// Core API client — tools + secrets
99 // ---------------------------------------------------------------------------
1010
1111 class ExecutorApiClient extends AtomHttpApi.Tag<ExecutorApiClient>()(
packages/clients/react/src/index.ts+3−0
@@ -32,3 +32,6 @@ export { · 31 unmodified lines
3232
3333 // Provider
3434 export { ExecutorProvider } from "./provider";
35+
36+// Plugin contracts
37+export type { SourcePlugin } from "./source-plugin";
packages/clients/react/src/source-plugin.ts+49−0
@@ -0,0 +1,49 @@
1+import type { ComponentType } from "react";
2+
3+/**
4+ * Contract between the shell and source plugins.
5+ *
6+ * The shell owns:
7+ * - Source list page (renders all sources, common delete action, "add" button)
8+ * - Source detail chrome (title bar, edit/delete buttons)
9+ * - Routing between sources
10+ *
11+ * The plugin owns:
12+ * - Everything inside `add` (could be 1 step or a multi-step wizard)
13+ * - Everything inside `edit` (the config view for that source type)
14+ * - Optional `summary` for the source list item
15+ */
16+export interface SourcePlugin {
17+ /** Unique key matching the SDK plugin key (e.g. "openapi") */
18+ readonly key: string;
19+
20+ /** Display label (e.g. "OpenAPI", "GraphQL") */
21+ readonly label: string;
22+
23+ /**
24+ * The "add source" flow.
25+ * Plugin controls the entire experience.
26+ * Call `onComplete` when done, `onCancel` to bail out.
27+ */
28+ readonly add: ComponentType<{
29+ readonly onComplete: () => void;
30+ readonly onCancel: () => void;
31+ }>;
32+
33+ /**
34+ * The source edit/detail view.
35+ * Rendered inside the shell's detail chrome.
36+ */
37+ readonly edit: ComponentType<{
38+ readonly sourceId: string;
39+ readonly onSave: () => void;
40+ }>;
41+
42+ /**
43+ * Optional summary for the source list item.
44+ * If not provided, the shell renders a default.
45+ */
46+ readonly summary?: ComponentType<{
47+ readonly sourceId: string;
48+ }>;
49+}
packages/core/api/src/api.ts+12−1
@@ -1,9 +1,10 @@
11 import { HttpApi, OpenApi } from "@effect/platform";
2+import type { HttpApiGroup } from "@effect/platform";
23
34 import { ToolsApi } from "./tools/api";
45 import { SecretsApi } from "./secrets/api";
56
6−export const ExecutorApi = HttpApi.make("executor")
7+export const CoreExecutorApi = HttpApi.make("executor")
78 .add(ToolsApi)
89 .add(SecretsApi)
910 .annotateContext(
@@ -12,3 +13,13 @@ export const ExecutorApi = HttpApi.make("executor") · 2 unmodified lines
1213 description: "Tool execution platform API",
1314 }),
1415 );
16+
17+/**
18+ * Compose the core API with a plugin group.
19+ */
20+export const addGroup = <G extends HttpApiGroup.HttpApiGroup.Any>(
21+ group: G,
22+) => CoreExecutorApi.add(group);
23+
24+/** Default API with no plugin groups */
25+export const ExecutorApi = CoreExecutorApi;
packages/core/api/src/index.ts+1−1
@@ -1,3 +1,3 @@
1−export { ExecutorApi } from "./api";
1+export { ExecutorApi, CoreExecutorApi, addGroup } from "./api";
22 export { ToolsApi } from "./tools/api";
33 export { SecretsApi } from "./secrets/api";
packages/plugins/openapi/package.json+20−1
@@ -4,7 +4,9 @@ · 3 unmodified lines
44 "type": "module",
55 "version": "0.0.0",
66 "exports": {
7− ".": "./src/index.ts"
7+ ".": "./src/index.ts",
8+ "./api": "./src/api/index.ts",
9+ "./react": "./src/react/index.ts"
810 },
911 "scripts": {
1012 "typecheck": "bunx tsc --noEmit -p tsconfig.json",
@@ -20,7 +22,24 @@ · 9 unmodified lines
2022 "openapi-types": "^12.1.3",
2123 "yaml": "^2.7.1"
2224 },
25+ "peerDependencies": {
26+ "react": "^19.1.0",
27+ "@effect-atom/atom-react": "^0.5.0",
28+ "@executor/api": "workspace:*",
29+ "@executor/react": "workspace:*"
30+ },
31+ "peerDependenciesMeta": {
32+ "react": { "optional": true },
33+ "@effect-atom/atom-react": { "optional": true },
34+ "@executor/api": { "optional": true },
35+ "@executor/react": { "optional": true }
36+ },
2337 "devDependencies": {
38+ "react": "^19.1.0",
39+ "@effect-atom/atom-react": "^0.5.0",
40+ "@executor/api": "workspace:*",
41+ "@executor/react": "workspace:*",
42+ "@types/react": "^19.1.0",
2443 "@effect/vitest": "catalog:",
2544 "@types/node": "catalog:",
2645 "bun-types": "catalog:",
packages/plugins/openapi/src/api/group.ts+90−0
@@ -0,0 +1,90 @@
1+import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2+import { Schema } from "effect";
3+import { ScopeId } from "@executor/sdk";
4+
5+import {
6+ OpenApiParseError,
7+ OpenApiExtractionError,
8+} from "../errors";
9+import { SpecPreview } from "../preview";
10+
11+// ---------------------------------------------------------------------------
12+// Params
13+// ---------------------------------------------------------------------------
14+
15+const scopeIdParam = HttpApiSchema.param("scopeId", ScopeId);
16+
17+// ---------------------------------------------------------------------------
18+// Payloads
19+// ---------------------------------------------------------------------------
20+
21+const AddSpecPayload = Schema.Struct({
22+ spec: Schema.String,
23+ baseUrl: Schema.optional(Schema.String),
24+ namespace: Schema.optional(Schema.String),
25+ headers: Schema.optional(
26+ Schema.Record({ key: Schema.String, value: Schema.Unknown }),
27+ ),
28+});
29+
30+const PreviewSpecPayload = Schema.Struct({
31+ spec: Schema.String,
32+});
33+
34+// ---------------------------------------------------------------------------
35+// Responses
36+// ---------------------------------------------------------------------------
37+
38+const AddSpecResponse = Schema.Struct({
39+ toolCount: Schema.Number,
40+ namespace: Schema.String,
41+});
42+
43+const ListSpecsResponse = Schema.Array(
44+ Schema.Struct({
45+ namespace: Schema.String,
46+ toolCount: Schema.Number,
47+ }),
48+);
49+
50+// ---------------------------------------------------------------------------
51+// Errors with HTTP status
52+// ---------------------------------------------------------------------------
53+
54+const ParseError = OpenApiParseError.annotations(
55+ HttpApiSchema.annotations({ status: 400 }),
56+);
57+const ExtractionError = OpenApiExtractionError.annotations(
58+ HttpApiSchema.annotations({ status: 400 }),
59+);
60+
61+// ---------------------------------------------------------------------------
62+// Group
63+// ---------------------------------------------------------------------------
64+
65+const namespaceParam = HttpApiSchema.param("namespace", Schema.String);
66+
67+export class OpenApiGroup extends HttpApiGroup.make("openapi")
68+ .add(
69+ HttpApiEndpoint.post("previewSpec")`/scopes/${scopeIdParam}/openapi/preview`
70+ .setPayload(PreviewSpecPayload)
71+ .addSuccess(SpecPreview)
72+ .addError(ParseError)
73+ .addError(ExtractionError),
74+ )
75+ .add(
76+ HttpApiEndpoint.post("addSpec")`/scopes/${scopeIdParam}/openapi/specs`
77+ .setPayload(AddSpecPayload)
78+ .addSuccess(AddSpecResponse)
79+ .addError(ParseError)
80+ .addError(ExtractionError),
81+ )
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+ )
90+ .prefix("/v1") {}
packages/plugins/openapi/src/api/handlers.ts+63−0
@@ -0,0 +1,63 @@
1+import { HttpApiBuilder } from "@effect/platform";
2+import { Context, Effect } from "effect";
3+
4+import { addGroup } from "@executor/api";
5+import type { OpenApiPluginExtension } from "../plugin";
6+import { OpenApiGroup } from "./group";
7+
8+// ---------------------------------------------------------------------------
9+// Service tag — the server provides the OpenAPI extension
10+// ---------------------------------------------------------------------------
11+
12+export class OpenApiExtensionService extends Context.Tag(
13+ "OpenApiExtensionService",
14+)<OpenApiExtensionService, OpenApiPluginExtension>() {}
15+
16+// ---------------------------------------------------------------------------
17+// Composed API — core + openapi group
18+// ---------------------------------------------------------------------------
19+
20+const ExecutorApiWithOpenApi = addGroup(OpenApiGroup);
21+
22+// ---------------------------------------------------------------------------
23+// Handlers
24+// ---------------------------------------------------------------------------
25+
26+export const OpenApiHandlers = HttpApiBuilder.group(
27+ ExecutorApiWithOpenApi,
28+ "openapi",
29+ (handlers) =>
30+ handlers
31+ .handle("previewSpec", ({ payload }) =>
32+ Effect.gen(function* () {
33+ const ext = yield* OpenApiExtensionService;
34+ return yield* ext.previewSpec(payload.spec);
35+ }).pipe(Effect.orDie),
36+ )
37+ .handle("addSpec", ({ payload }) =>
38+ Effect.gen(function* () {
39+ const ext = yield* OpenApiExtensionService;
40+ const result = yield* ext.addSpec({
41+ spec: payload.spec,
42+ baseUrl: payload.baseUrl,
43+ namespace: payload.namespace,
44+ headers: payload.headers as Record<string, string> | undefined,
45+ });
46+ return {
47+ toolCount: result.toolCount,
48+ namespace: payload.namespace ?? "api",
49+ };
50+ }).pipe(Effect.orDie),
51+ )
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+ ),
63+);
packages/plugins/openapi/src/api/index.ts+2−0
@@ -0,0 +1,2 @@
1+export { OpenApiGroup } from "./group";
2+export { OpenApiHandlers, OpenApiExtensionService } from "./handlers";
packages/plugins/openapi/src/react/AddOpenApiSource.tsx+55−0
@@ -0,0 +1,55 @@
1+import { useState } from "react";
2+
3+export default function AddOpenApiSource(props: {
4+ onComplete: () => void;
5+ onCancel: () => void;
6+}) {
7+ const [step, setStep] = useState<"url" | "auth" | "confirm">("url");
8+ const [specUrl, setSpecUrl] = useState("");
9+
10+ return (
11+ <div>
12+ <h3>Add OpenAPI Source</h3>
13+
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+ )}
33+
34+ {step === "auth" && (
35+ <div>
36+ <p>Configure authentication headers…</p>
37+ <div>
38+ <button onClick={() => setStep("url")}>Back</button>
39+ <button onClick={() => setStep("confirm")}>Next</button>
40+ </div>
41+ </div>
42+ )}
43+
44+ {step === "confirm" && (
45+ <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>
51+ </div>
52+ )}
53+ </div>
54+ );
55+}
packages/plugins/openapi/src/react/EditOpenApiSource.tsx+13−0
@@ -0,0 +1,13 @@
1+export default function EditOpenApiSource(props: {
2+ sourceId: string;
3+ onSave: () => void;
4+}) {
5+ return (
6+ <div>
7+ <h3>Edit OpenAPI Source</h3>
8+ <p>Source: {props.sourceId}</p>
9+ {/* TODO: show spec info, auth config, operation list */}
10+ <button onClick={props.onSave}>Save</button>
11+ </div>
12+ );
13+}
packages/plugins/openapi/src/react/OpenApiSourceSummary.tsx+9−0
@@ -0,0 +1,9 @@
1+export default function OpenApiSourceSummary(props: {
2+ sourceId: string;
3+}) {
4+ return (
5+ <span>
6+ OpenAPI · {props.sourceId}
7+ </span>
8+ );
9+}
packages/plugins/openapi/src/react/atoms.ts+35−0
@@ -0,0 +1,35 @@
1+import { ScopeId } from "@executor/sdk";
2+
3+import { OpenApiClient } from "./client";
4+
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+
21+// ---------------------------------------------------------------------------
22+// Mutation atoms
23+// ---------------------------------------------------------------------------
24+
25+export const previewOpenApiSpec = OpenApiClient.mutation(
26+ "openapi",
27+ "previewSpec",
28+);
29+
30+export const addOpenApiSpec = OpenApiClient.mutation("openapi", "addSpec");
31+
32+export const removeOpenApiSpec = OpenApiClient.mutation(
33+ "openapi",
34+ "removeSpec",
35+);
packages/plugins/openapi/src/react/client.ts+20−0
@@ -0,0 +1,20 @@
1+import { AtomHttpApi } from "@effect-atom/atom-react";
2+import { FetchHttpClient } from "@effect/platform";
3+import { addGroup } from "@executor/api";
4+import { getBaseUrl } from "@executor/react";
5+import { OpenApiGroup } from "../api/group";
6+
7+// ---------------------------------------------------------------------------
8+// OpenAPI-aware client — core routes + openapi routes
9+// ---------------------------------------------------------------------------
10+
11+const OpenApiApi = addGroup(OpenApiGroup);
12+
13+export const OpenApiClient = AtomHttpApi.Tag<"OpenApiClient">()(
14+ "OpenApiClient",
15+ {
16+ api: OpenApiApi,
17+ httpClient: FetchHttpClient.layer,
18+ baseUrl: getBaseUrl(),
19+ },
20+);
packages/plugins/openapi/src/react/index.ts+8−0
@@ -0,0 +1,8 @@
1+export { openApiSourcePlugin } from "./source-plugin";
2+export { OpenApiClient } from "./client";
3+export {
4+ openApiSpecsAtom,
5+ previewOpenApiSpec,
6+ addOpenApiSpec,
7+ removeOpenApiSpec,
8+} from "./atoms";
packages/plugins/openapi/src/react/source-plugin.ts+14−0
@@ -0,0 +1,14 @@
1+import { lazy } from "react";
2+import type { SourcePlugin } from "@executor/react";
3+
4+// ---------------------------------------------------------------------------
5+// OpenAPI source plugin — lazy-loaded components
6+// ---------------------------------------------------------------------------
7+
8+export const openApiSourcePlugin: SourcePlugin = {
9+ key: "openapi",
10+ label: "OpenAPI",
11+ add: lazy(() => import("./AddOpenApiSource")),
12+ edit: lazy(() => import("./EditOpenApiSource")),
13+ summary: lazy(() => import("./OpenApiSourceSummary")),
14+};
packages/plugins/openapi/tsconfig.json+4−2
@@ -6,10 +6,11 @@ · 5 unmodified lines
66 "strict": true,
77 "skipLibCheck": true,
88 "noEmit": true,
9− "lib": ["ES2022"],
9+ "lib": ["ES2022", "DOM"],
1010 "types": ["bun-types", "node"],
1111 "noUnusedLocals": true,
1212 "noImplicitOverride": true,
13+ "jsx": "react-jsx",
1314 "plugins": [
1415 {
1516 "name": "@effect/language-service",
@@ -20,6 +21,7 @@ · 4 unmodified lines
2021 ]
2122 },
2223 "include": [
23− "src/**/*.ts"
24+ "src/**/*.ts",
25+ "src/**/*.tsx"
2426 ]
2527 }