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
160 "yaml": "^2.7.1",160 "yaml": "^2.7.1",
161 },161 },
162 "devDependencies": {162 "devDependencies": {
163 "@effect-atom/atom-react": "^0.5.0",
163 "@effect/vitest": "catalog:",164 "@effect/vitest": "catalog:",
165 "@executor/api": "workspace:*",
166 "@executor/react": "workspace:*",
164 "@types/node": "catalog:",167 "@types/node": "catalog:",
168 "@types/react": "^19.1.0",
165 "bun-types": "catalog:",169 "bun-types": "catalog:",
170 "react": "^19.1.0",
166 "vitest": "catalog:",171 "vitest": "catalog:",
167 },172 },
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 ],
168 },185 },
169 },186 },
170 "catalog": {187 "catalog": {
packages/clients/react/src/client.ts+1−1
@@ -5,7 +5,7 @@ import { ExecutorApi } from "@executor/api"; · 4 unmodified lines
5import { getBaseUrl } from "./base-url";5import { getBaseUrl } from "./base-url";
6 6
7// ---------------------------------------------------------------------------7// ---------------------------------------------------------------------------
8// Typed HTTP API client — cached per base URL8// Core API client — tools + secrets
9// ---------------------------------------------------------------------------9// ---------------------------------------------------------------------------
10 10
11class ExecutorApiClient extends AtomHttpApi.Tag<ExecutorApiClient>()(11class ExecutorApiClient extends AtomHttpApi.Tag<ExecutorApiClient>()(
packages/clients/react/src/index.ts+3−0
@@ -32,3 +32,6 @@ export { · 31 unmodified lines
32 32
33// Provider33// Provider
34export { ExecutorProvider } from "./provider";34export { ExecutorProvider } from "./provider";
35
36// Plugin contracts
37export type { SourcePlugin } from "./source-plugin";
packages/clients/react/src/source-plugin.ts+49−0
@@ -0,0 +1,49 @@
1import 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 */
16export 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 @@
1import { HttpApi, OpenApi } from "@effect/platform";1import { HttpApi, OpenApi } from "@effect/platform";
2import type { HttpApiGroup } from "@effect/platform";
2 3
3import { ToolsApi } from "./tools/api";4import { ToolsApi } from "./tools/api";
4import { SecretsApi } from "./secrets/api";5import { SecretsApi } from "./secrets/api";
5 6
6export const ExecutorApi = HttpApi.make("executor")7export const CoreExecutorApi = HttpApi.make("executor")
7 .add(ToolsApi)8 .add(ToolsApi)
8 .add(SecretsApi)9 .add(SecretsApi)
9 .annotateContext(10 .annotateContext(
@@ -12,3 +13,13 @@ export const ExecutorApi = HttpApi.make("executor") · 2 unmodified lines
12 description: "Tool execution platform API",13 description: "Tool execution platform API",
13 }),14 }),
14 );15 );
16
17/**
18 * Compose the core API with a plugin group.
19 */
20export const addGroup = <G extends HttpApiGroup.HttpApiGroup.Any>(
21 group: G,
22) => CoreExecutorApi.add(group);
23
24/** Default API with no plugin groups */
25export const ExecutorApi = CoreExecutorApi;
packages/core/api/src/index.ts+1−1
@@ -1,3 +1,3 @@
1export { ExecutorApi } from "./api";1export { ExecutorApi, CoreExecutorApi, addGroup } from "./api";
2export { ToolsApi } from "./tools/api";2export { ToolsApi } from "./tools/api";
3export { SecretsApi } from "./secrets/api";3export { SecretsApi } from "./secrets/api";
packages/plugins/openapi/package.json+20−1
@@ -4,7 +4,9 @@ · 3 unmodified lines
4 "type": "module",4 "type": "module",
5 "version": "0.0.0",5 "version": "0.0.0",
6 "exports": {6 "exports": {
7 ".": "./src/index.ts"7 ".": "./src/index.ts",
8 "./api": "./src/api/index.ts",
9 "./react": "./src/react/index.ts"
8 },10 },
9 "scripts": {11 "scripts": {
10 "typecheck": "bunx tsc --noEmit -p tsconfig.json",12 "typecheck": "bunx tsc --noEmit -p tsconfig.json",
@@ -20,7 +22,24 @@ · 9 unmodified lines
20 "openapi-types": "^12.1.3",22 "openapi-types": "^12.1.3",
21 "yaml": "^2.7.1"23 "yaml": "^2.7.1"
22 },24 },
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 },
23 "devDependencies": {37 "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",
24 "@effect/vitest": "catalog:",43 "@effect/vitest": "catalog:",
25 "@types/node": "catalog:",44 "@types/node": "catalog:",
26 "bun-types": "catalog:",45 "bun-types": "catalog:",
packages/plugins/openapi/src/api/group.ts+90−0
@@ -0,0 +1,90 @@
1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2import { Schema } from "effect";
3import { ScopeId } from "@executor/sdk";
4
5import {
6 OpenApiParseError,
7 OpenApiExtractionError,
8} from "../errors";
9import { SpecPreview } from "../preview";
10
11// ---------------------------------------------------------------------------
12// Params
13// ---------------------------------------------------------------------------
14
15const scopeIdParam = HttpApiSchema.param("scopeId", ScopeId);
16
17// ---------------------------------------------------------------------------
18// Payloads
19// ---------------------------------------------------------------------------
20
21const 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
30const PreviewSpecPayload = Schema.Struct({
31 spec: Schema.String,
32});
33
34// ---------------------------------------------------------------------------
35// Responses
36// ---------------------------------------------------------------------------
37
38const AddSpecResponse = Schema.Struct({
39 toolCount: Schema.Number,
40 namespace: Schema.String,
41});
42
43const 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
54const ParseError = OpenApiParseError.annotations(
55 HttpApiSchema.annotations({ status: 400 }),
56);
57const ExtractionError = OpenApiExtractionError.annotations(
58 HttpApiSchema.annotations({ status: 400 }),
59);
60
61// ---------------------------------------------------------------------------
62// Group
63// ---------------------------------------------------------------------------
64
65const namespaceParam = HttpApiSchema.param("namespace", Schema.String);
66
67export 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 @@
1import { HttpApiBuilder } from "@effect/platform";
2import { Context, Effect } from "effect";
3
4import { addGroup } from "@executor/api";
5import type { OpenApiPluginExtension } from "../plugin";
6import { OpenApiGroup } from "./group";
7
8// ---------------------------------------------------------------------------
9// Service tag — the server provides the OpenAPI extension
10// ---------------------------------------------------------------------------
11
12export class OpenApiExtensionService extends Context.Tag(
13 "OpenApiExtensionService",
14)<OpenApiExtensionService, OpenApiPluginExtension>() {}
15
16// ---------------------------------------------------------------------------
17// Composed API — core + openapi group
18// ---------------------------------------------------------------------------
19
20const ExecutorApiWithOpenApi = addGroup(OpenApiGroup);
21
22// ---------------------------------------------------------------------------
23// Handlers
24// ---------------------------------------------------------------------------
25
26export 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 @@
1export { OpenApiGroup } from "./group";
2export { OpenApiHandlers, OpenApiExtensionService } from "./handlers";
packages/plugins/openapi/src/react/AddOpenApiSource.tsx+55−0
@@ -0,0 +1,55 @@
1import { useState } from "react";
2
3export 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 @@
1export 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 @@
1export 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 @@
1import { ScopeId } from "@executor/sdk";
2
3import { OpenApiClient } from "./client";
4
5// ---------------------------------------------------------------------------
6// Default scope
7// ---------------------------------------------------------------------------
8
9const DEFAULT_SCOPE = ScopeId.make("default");
10
11// ---------------------------------------------------------------------------
12// Query atoms
13// ---------------------------------------------------------------------------
14
15export 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
25export const previewOpenApiSpec = OpenApiClient.mutation(
26 "openapi",
27 "previewSpec",
28);
29
30export const addOpenApiSpec = OpenApiClient.mutation("openapi", "addSpec");
31
32export const removeOpenApiSpec = OpenApiClient.mutation(
33 "openapi",
34 "removeSpec",
35);
packages/plugins/openapi/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 { OpenApiGroup } from "../api/group";
6
7// ---------------------------------------------------------------------------
8// OpenAPI-aware client — core routes + openapi routes
9// ---------------------------------------------------------------------------
10
11const OpenApiApi = addGroup(OpenApiGroup);
12
13export 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 @@
1export { openApiSourcePlugin } from "./source-plugin";
2export { OpenApiClient } from "./client";
3export {
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 @@
1import { lazy } from "react";
2import type { SourcePlugin } from "@executor/react";
3
4// ---------------------------------------------------------------------------
5// OpenAPI source plugin — lazy-loaded components
6// ---------------------------------------------------------------------------
7
8export 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
6 "strict": true,6 "strict": true,
7 "skipLibCheck": true,7 "skipLibCheck": true,
8 "noEmit": true,8 "noEmit": true,
9 "lib": ["ES2022"],9 "lib": ["ES2022", "DOM"],
10 "types": ["bun-types", "node"],10 "types": ["bun-types", "node"],
11 "noUnusedLocals": true,11 "noUnusedLocals": true,
12 "noImplicitOverride": true,12 "noImplicitOverride": true,
13 "jsx": "react-jsx",
13 "plugins": [14 "plugins": [
14 {15 {
15 "name": "@effect/language-service",16 "name": "@effect/language-service",
@@ -20,6 +21,7 @@ · 4 unmodified lines
20 ]21 ]
21 },22 },
22 "include": [23 "include": [
23 "src/**/*.ts"24 "src/**/*.ts",
25 "src/**/*.tsx"
24 ]26 ]
25}27}