History

Replace repository contents with v4 snapshot

Rhys Sullivan committed 7 months ago

Commit 4fcdba41 parent be4bbf5865 files · +36247 −404676Browse files

Showing the changes to packages/plugins/openapi/src (26 of 865 files). Show all

Unified · Split

26 files
  1. packages/plugins/openapi/src/api/group.ts +73 −0
  2. packages/plugins/openapi/src/api/handlers.ts +53 −0
  3. packages/plugins/openapi/src/api/index.ts +2 −0
  4. packages/plugins/openapi/src/react/AddOpenApiSource.tsx +760 −0
  5. packages/plugins/openapi/src/react/EditOpenApiSource.tsx +13 −0
  6. packages/plugins/openapi/src/react/OpenApiSourceSummary.tsx +9 −0
  7. packages/plugins/openapi/src/react/atoms.ts +12 −0
  8. packages/plugins/openapi/src/react/client.ts +20 −0
  9. packages/plugins/openapi/src/react/index.ts +6 −0
  10. packages/plugins/openapi/src/react/source-plugin.ts +12 −0
  11. packages/plugins/openapi/src/sdk/definitions.ts +255 −0
  12. packages/plugins/openapi/src/sdk/errors.ts +25 −0
  13. packages/plugins/openapi/src/sdk/extract.ts +255 −0
  14. packages/plugins/openapi/src/sdk/index.test.ts +181 −0
  15. packages/plugins/openapi/src/sdk/index.ts +45 −0
  16. packages/plugins/openapi/src/sdk/invoke.ts +374 −0
  17. packages/plugins/openapi/src/sdk/kv-operation-store.ts +120 −0
  18. packages/plugins/openapi/src/sdk/openapi-utils.ts +88 −0
  19. packages/plugins/openapi/src/sdk/operation-store.ts +46 −0
  20. packages/plugins/openapi/src/sdk/parse.ts +69 −0
  21. packages/plugins/openapi/src/sdk/plugin.test.ts +466 −0
  22. packages/plugins/openapi/src/sdk/plugin.ts +361 −0
  23. packages/plugins/openapi/src/sdk/presets.ts +66 −0
  24. packages/plugins/openapi/src/sdk/preview.ts +192 −0
  25. packages/plugins/openapi/src/sdk/real-specs.test.ts +225 −0
  26. packages/plugins/openapi/src/sdk/types.ts +128 −0
packages/plugins/openapi/src/api/group.ts+73−0
@@ -0,0 +1,73 @@
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 "../sdk/errors";
9+import { SpecPreview } from "../sdk/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+// ---------------------------------------------------------------------------
44+// Errors with HTTP status
45+// ---------------------------------------------------------------------------
46+
47+const ParseError = OpenApiParseError.annotations(
48+ HttpApiSchema.annotations({ status: 400 }),
49+);
50+const ExtractionError = OpenApiExtractionError.annotations(
51+ HttpApiSchema.annotations({ status: 400 }),
52+);
53+
54+// ---------------------------------------------------------------------------
55+// Group
56+// ---------------------------------------------------------------------------
57+
58+export class OpenApiGroup extends HttpApiGroup.make("openapi")
59+ .add(
60+ HttpApiEndpoint.post("previewSpec")`/scopes/${scopeIdParam}/openapi/preview`
61+ .setPayload(PreviewSpecPayload)
62+ .addSuccess(SpecPreview)
63+ .addError(ParseError)
64+ .addError(ExtractionError),
65+ )
66+ .add(
67+ HttpApiEndpoint.post("addSpec")`/scopes/${scopeIdParam}/openapi/specs`
68+ .setPayload(AddSpecPayload)
69+ .addSuccess(AddSpecResponse)
70+ .addError(ParseError)
71+ .addError(ExtractionError),
72+ )
73+ .prefix("/v1") {}
packages/plugins/openapi/src/api/handlers.ts+53−0
@@ -0,0 +1,53 @@
1+import { HttpApiBuilder } from "@effect/platform";
2+import { Context, Effect } from "effect";
3+
4+import { addGroup } from "@executor/api";
5+import type { OpenApiPluginExtension, HeaderValue } from "../sdk/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, HeaderValue> | undefined,
45+ });
46+ return {
47+ toolCount: result.toolCount,
48+ namespace: payload.namespace ?? "api",
49+ };
50+ }).pipe(Effect.orDie),
51+ )
52+ ,
53+);
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+760−0
@@ -0,0 +1,760 @@
1+import { useState, useEffect, useRef } from "react";
2+import { useAtomSet, useAtomValue, useAtomRefresh, Result } from "@effect-atom/atom-react";
3+import { Option } from "effect";
4+
5+import {
6+ secretsAtom,
7+ setSecret,
8+ resolveSecret,
9+ useScope,
10+ SecretPicker,
11+ type SecretPickerSecret,
12+} from "@executor/react";
13+import { SecretId } from "@executor/sdk";
14+import { Button } from "@executor/ui/components/button";
15+import { Input } from "@executor/ui/components/input";
16+import { Label } from "@executor/ui/components/label";
17+import { Textarea } from "@executor/ui/components/textarea";
18+import { Badge } from "@executor/ui/components/badge";
19+import { RadioGroup, RadioGroupItem } from "@executor/ui/components/radio-group";
20+import { Spinner } from "@executor/ui/components/spinner";
21+import { previewOpenApiSpec, addOpenApiSpec } from "./atoms";
22+import type { SpecPreview, HeaderPreset } from "../sdk/preview";
23+import type { HeaderValue } from "../sdk/types";
24+
25+// ---------------------------------------------------------------------------
26+// Inline secret creation
27+// ---------------------------------------------------------------------------
28+
29+function InlineCreateSecret(props: {
30+ headerName: string;
31+ suggestedId: string;
32+ onCreated: (secretId: string) => void;
33+ onCancel: () => void;
34+}) {
35+ const [secretId, setSecretId] = useState(props.suggestedId);
36+ const [secretName, setSecretName] = useState(props.headerName);
37+ const [secretValue, setSecretValue] = useState("");
38+ const [saving, setSaving] = useState(false);
39+ const [error, setError] = useState<string | null>(null);
40+ const scopeId = useScope();
41+ const doSet = useAtomSet(setSecret, { mode: "promise" });
42+ const refreshSecrets = useAtomRefresh(secretsAtom(scopeId));
43+
44+ const handleSave = async () => {
45+ if (!secretId.trim() || !secretValue.trim()) return;
46+ setSaving(true);
47+ setError(null);
48+ try {
49+ await doSet({
50+ path: { scopeId },
51+ payload: {
52+ id: SecretId.make(secretId.trim()),
53+ name: secretName.trim() || secretId.trim(),
54+ value: secretValue.trim(),
55+ purpose: `Auth header: ${props.headerName}`,
56+ },
57+ });
58+ refreshSecrets();
59+ props.onCreated(secretId.trim());
60+ } catch (e) {
61+ setError(e instanceof Error ? e.message : "Failed to save secret");
62+ setSaving(false);
63+ }
64+ };
65+
66+ return (
67+ <div className="rounded-lg border border-primary/20 bg-primary/[0.02] p-3 space-y-2.5">
68+ <p className="text-[11px] font-semibold text-primary tracking-wide uppercase">New secret</p>
69+ <div className="grid grid-cols-2 gap-2">
70+ <div className="space-y-1">
71+ <Label className="text-[10px] uppercase tracking-wider text-muted-foreground">ID</Label>
72+ <Input
73+ value={secretId}
74+ onChange={(e) => setSecretId((e.target as HTMLInputElement).value)}
75+ placeholder="my-api-token"
76+ className="h-8 text-xs font-mono"
77+ />
78+ </div>
79+ <div className="space-y-1">
80+ <Label className="text-[10px] uppercase tracking-wider text-muted-foreground">Label</Label>
81+ <Input
82+ value={secretName}
83+ onChange={(e) => setSecretName((e.target as HTMLInputElement).value)}
84+ placeholder="API Token"
85+ className="h-8 text-xs"
86+ />
87+ </div>
88+ </div>
89+ <div className="space-y-1">
90+ <Label className="text-[10px] uppercase tracking-wider text-muted-foreground">Value</Label>
91+ <Input
92+ type="password"
93+ value={secretValue}
94+ onChange={(e) => setSecretValue((e.target as HTMLInputElement).value)}
95+ placeholder="paste your token or key…"
96+ className="h-8 text-xs font-mono"
97+ />
98+ </div>
99+ {error && <p className="text-[11px] text-destructive">{error}</p>}
100+ <div className="flex gap-1.5 pt-0.5">
101+ <Button variant="outline" size="xs" onClick={props.onCancel}>
102+ Cancel
103+ </Button>
104+ <Button
105+ size="xs"
106+ onClick={handleSave}
107+ disabled={!secretId.trim() || !secretValue.trim() || saving}
108+ >
109+ {saving ? "Saving…" : "Create & use"}
110+ </Button>
111+ </div>
112+ </div>
113+ );
114+}
115+
116+// ---------------------------------------------------------------------------
117+// Header value preview — shows what the header will look like
118+// ---------------------------------------------------------------------------
119+
120+type ResolveState =
121+ | { status: "hidden" }
122+ | { status: "loading" }
123+ | { status: "revealed"; value: string }
124+ | { status: "error" };
125+
126+function HeaderValuePreview(props: {
127+ headerName: string;
128+ secretId: string;
129+ prefix?: string;
130+}) {
131+ const { headerName, secretId, prefix } = props;
132+ const scopeId = useScope();
133+ const [state, setState] = useState<ResolveState>({ status: "hidden" });
134+ const doResolve = useAtomSet(resolveSecret, { mode: "promise" });
135+
136+ const handleToggle = async () => {
137+ if (state.status === "revealed") {
138+ setState({ status: "hidden" });
139+ return;
140+ }
141+ setState({ status: "loading" });
142+ try {
143+ const result = await doResolve({
144+ path: {
145+ scopeId,
146+ secretId: SecretId.make(secretId),
147+ },
148+ });
149+ setState({ status: "revealed", value: result.value });
150+ } catch {
151+ setState({ status: "error" });
152+ }
153+ };
154+
155+ const displayValue =
156+ state.status === "revealed" ? state.value
157+ : state.status === "error" ? "failed to resolve"
158+ : "•".repeat(12);
159+ const isLoading = state.status === "loading";
160+ const isRevealed = state.status === "revealed";
161+
162+ return (
163+ <div className="flex items-center gap-1.5 rounded-md border border-border bg-muted/30 px-2.5 py-1.5 font-mono text-xs">
164+ <span className="text-muted-foreground shrink-0">{headerName}:</span>
165+ <span className="text-foreground truncate">
166+ {prefix && <span className="text-muted-foreground">{prefix}</span>}
167+ {displayValue}
168+ </span>
169+ <Button
170+ variant="ghost"
171+ size="icon-xs"
172+ className="ml-auto shrink-0"
173+ onClick={handleToggle}
174+ disabled={isLoading}
175+ >
176+ {isLoading ? (
177+ <Spinner className="size-3" />
178+ ) : isRevealed ? (
179+ <svg viewBox="0 0 16 16" className="size-3.5" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round">
180+ <path d="M2 2l12 12" />
181+ <path d="M6.5 6.5a2 2 0 0 0 3 3" />
182+ <path d="M3.5 5.5C2.3 6.7 1.5 8 1.5 8s2.5 4.5 6.5 4.5c1 0 1.9-.3 2.7-.7" />
183+ <path d="M10.7 10.7c2-1.4 3.3-3.2 3.8-3.7 0 0-2.5-5-6.5-5-.7 0-1.4.1-2 .4" />
184+ </svg>
185+ ) : (
186+ <svg viewBox="0 0 16 16" className="size-3.5" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round">
187+ <path d="M1.5 8s2.5-4.5 6.5-4.5S14.5 8 14.5 8s-2.5 4.5-6.5 4.5S1.5 8 1.5 8z" />
188+ <circle cx="8" cy="8" r="2" />
189+ </svg>
190+ )}
191+ </Button>
192+ </div>
193+ );
194+}
195+
196+// ---------------------------------------------------------------------------
197+// Header presets
198+// ---------------------------------------------------------------------------
199+
200+const HEADER_PRESETS = [
201+ { key: "bearer", label: "Bearer Token", name: "Authorization", prefix: "Bearer " },
202+ { key: "basic", label: "Basic Auth", name: "Authorization", prefix: "Basic " },
203+ { key: "api-key", label: "API Key", name: "X-API-Key" },
204+ { key: "auth-token", label: "Auth Token", name: "X-Auth-Token" },
205+ { key: "access-token", label: "Access Token", name: "X-Access-Token" },
206+ { key: "cookie", label: "Cookie", name: "Cookie" },
207+ { key: "custom", label: "Custom", name: "" },
208+] as const;
209+
210+// ---------------------------------------------------------------------------
211+// Custom header row — pick a preset, then pick a secret
212+// ---------------------------------------------------------------------------
213+
214+function CustomHeaderRow(props: {
215+ name: string;
216+ prefix?: string;
217+ presetKey?: string;
218+ secretId: string | null;
219+ onChange: (update: { name: string; prefix?: string; presetKey?: string }) => void;
220+ onSelectSecret: (secretId: string) => void;
221+ onRemove: () => void;
222+ existingSecrets: readonly SecretPickerSecret[];
223+}) {
224+ const [creating, setCreating] = useState(false);
225+ const { name, prefix, presetKey, secretId, onChange, onSelectSecret, onRemove, existingSecrets } = props;
226+
227+ const isCustom = presetKey === "custom";
228+ const suggestedId = name.toLowerCase().replace(/[^a-z0-9]+/g, "-") || "custom-header";
229+
230+ if (creating) {
231+ return (
232+ <InlineCreateSecret
233+ headerName={name || "Custom Header"}
234+ suggestedId={suggestedId}
235+ onCreated={(id) => {
236+ onSelectSecret(id);
237+ setCreating(false);
238+ }}
239+ onCancel={() => setCreating(false)}
240+ />
241+ );
242+ }
243+
244+ return (
245+ <div className="rounded-lg border border-border bg-card p-3 space-y-2.5">
246+ <div className="flex items-center justify-between">
247+ <Label className="text-[10px] uppercase tracking-wider text-muted-foreground">Header</Label>
248+ <Button variant="ghost" size="xs" className="text-muted-foreground hover:text-destructive" onClick={onRemove}>
249+ Remove
250+ </Button>
251+ </div>
252+
253+ {/* Preset chips */}
254+ <div className="flex flex-wrap gap-1">
255+ {HEADER_PRESETS.map((p) => (
256+ <button
257+ key={p.key}
258+ onClick={() =>
259+ onChange({
260+ name: p.name,
261+ prefix: (p as { prefix?: string }).prefix,
262+ presetKey: p.key,
263+ })
264+ }
265+ className={`rounded-md border px-2 py-1 text-xs font-medium transition-colors ${
266+ presetKey === p.key
267+ ? "border-primary/50 bg-primary/10 text-primary"
268+ : "border-border bg-background text-muted-foreground hover:text-foreground hover:bg-accent/50"
269+ }`}
270+ >
271+ {p.label}
272+ </button>
273+ ))}
274+ </div>
275+
276+ {/* Name + prefix fields — always visible once a preset is picked */}
277+ {presetKey !== undefined && (
278+ <div className="grid grid-cols-2 gap-2">
279+ <div className="space-y-1">
280+ <Label className="text-[10px] uppercase tracking-wider text-muted-foreground">Name</Label>
281+ <Input
282+ value={name}
283+ onChange={(e) => onChange({ name: (e.target as HTMLInputElement).value, prefix, presetKey: isCustom ? "custom" : presetKey })}
284+ placeholder="Authorization"
285+ className="h-8 text-xs font-mono"
286+ />
287+ </div>
288+ <div className="space-y-1">
289+ <Label className="text-[10px] uppercase tracking-wider text-muted-foreground">Prefix <span className="normal-case tracking-normal font-normal text-muted-foreground/60">(opt.)</span></Label>
290+ <Input
291+ value={prefix ?? ""}
292+ onChange={(e) => onChange({ name, prefix: (e.target as HTMLInputElement).value || undefined, presetKey: isCustom ? "custom" : presetKey })}
293+ placeholder="Bearer "
294+ className="h-8 text-xs font-mono"
295+ />
296+ </div>
297+ </div>
298+ )}
299+
300+ {/* Secret picker */}
301+ {presetKey !== undefined && name.trim() && (
302+ <div className="flex items-center gap-1.5">
303+ <div className="flex-1 min-w-0">
304+ <SecretPicker
305+ value={secretId}
306+ onSelect={onSelectSecret}
307+ secrets={existingSecrets}
308+ />
309+ </div>
310+ <Button variant="outline" size="sm" className="shrink-0" onClick={() => setCreating(true)}>
311+ + New
312+ </Button>
313+ </div>
314+ )}
315+
316+ {/* Preview */}
317+ {secretId && name.trim() && (
318+ <HeaderValuePreview
319+ headerName={name.trim()}
320+ secretId={secretId}
321+ prefix={prefix}
322+ />
323+ )}
324+ </div>
325+ );
326+}
327+
328+// ---------------------------------------------------------------------------
329+// Helpers
330+// ---------------------------------------------------------------------------
331+
332+function prefixForHeader(preset: HeaderPreset, headerName: string): string | undefined {
333+ const label = preset.label.toLowerCase();
334+ if (headerName.toLowerCase() === "authorization") {
335+ if (label.includes("bearer")) return "Bearer ";
336+ if (label.includes("basic")) return "Basic ";
337+ }
338+ return undefined;
339+}
340+
341+function matchPresetKey(name: string, prefix?: string): string {
342+ if (name === "Authorization" && prefix === "Bearer ") return "bearer";
343+ if (name === "Authorization" && prefix === "Basic ") return "basic";
344+ if (name === "X-API-Key") return "api-key";
345+ if (name === "X-Auth-Token") return "auth-token";
346+ if (name === "X-Access-Token") return "access-token";
347+ if (name === "Cookie") return "cookie";
348+ return "custom";
349+}
350+
351+function presetEntriesFromHeaderPreset(preset: HeaderPreset) {
352+ return preset.secretHeaders.map((headerName) => {
353+ const prefix = prefixForHeader(preset, headerName);
354+ return {
355+ name: headerName,
356+ secretId: null as string | null,
357+ prefix,
358+ presetKey: matchPresetKey(headerName, prefix),
359+ fromPreset: true,
360+ };
361+ });
362+}
363+
364+// ---------------------------------------------------------------------------
365+// Main component — single progressive form
366+// ---------------------------------------------------------------------------
367+
368+export default function AddOpenApiSource(props: {
369+ onComplete: () => void;
370+ onCancel: () => void;
371+ initialUrl?: string;
372+}) {
373+ // Spec input
374+ const [specUrl, setSpecUrl] = useState(props.initialUrl ?? "");
375+ const [analyzing, setAnalyzing] = useState(false);
376+ const [analyzeError, setAnalyzeError] = useState<string | null>(null);
377+
378+ // After analysis
379+ const [preview, setPreview] = useState<SpecPreview | null>(null);
380+ const [baseUrl, setBaseUrl] = useState("");
381+
382+ // Auth
383+ const [presetIndex, setPresetIndex] = useState(0);
384+ const [customHeaders, setCustomHeaders] = useState<Array<{ name: string; secretId: string | null; prefix?: string; presetKey?: string; fromPreset?: boolean }>>([]);
385+
386+ // Submit
387+ const [adding, setAdding] = useState(false);
388+ const [addError, setAddError] = useState<string | null>(null);
389+
390+ const scopeId = useScope();
391+ const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promise" });
392+ const doAdd = useAtomSet(addOpenApiSpec, { mode: "promise" });
393+ const secrets = useAtomValue(secretsAtom(scopeId));
394+ const autoAnalyzed = useRef(false);
395+
396+ useEffect(() => {
397+ if (props.initialUrl && !autoAnalyzed.current) {
398+ autoAnalyzed.current = true;
399+ handleAnalyze();
400+ }
401+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
402+
403+ const secretList: readonly SecretPickerSecret[] = Result.match(secrets, {
404+ onInitial: () => [] as SecretPickerSecret[],
405+ onFailure: () => [] as SecretPickerSecret[],
406+ onSuccess: ({ value }) =>
407+ value.map((s) => ({
408+ id: s.id,
409+ name: s.name,
410+ provider: s.provider ? String(s.provider) : undefined,
411+ })),
412+ });
413+
414+ // ---- Derived state ----
415+
416+ const presets = preview?.headerPresets ?? [];
417+ const hasAuth = presets.length > 0;
418+ const servers = (preview?.servers ?? []) as Array<{ url?: string }>;
419+
420+ const allHeaders: Record<string, HeaderValue> = {};
421+ for (const ch of customHeaders) {
422+ if (ch.name.trim() && ch.secretId) {
423+ allHeaders[ch.name.trim()] = { secretId: ch.secretId, ...(ch.prefix ? { prefix: ch.prefix } : {}) };
424+ }
425+ }
426+ const hasHeaders = Object.keys(allHeaders).length > 0;
427+
428+ const customHeadersValid = customHeaders.every(
429+ (ch) => ch.name.trim() && ch.secretId,
430+ );
431+
432+ const canAdd =
433+ preview !== null &&
434+ baseUrl.trim().length > 0 &&
435+ (customHeaders.length === 0 || customHeadersValid);
436+
437+ // ---- Handlers ----
438+
439+ const handleAnalyze = async () => {
440+ setAnalyzing(true);
441+ setAnalyzeError(null);
442+ setAddError(null);
443+ try {
444+ const result = await doPreview({
445+ path: { scopeId },
446+ payload: { spec: specUrl },
447+ });
448+ setPreview(result);
449+
450+ const firstUrl = (result.servers as Array<{ url?: string }>)?.[0]?.url;
451+ if (firstUrl) setBaseUrl(firstUrl);
452+
453+ const newPresetIndex = result.headerPresets.length > 0 ? 0 : -1;
454+ setPresetIndex(newPresetIndex);
455+ setCustomHeaders(
456+ newPresetIndex >= 0
457+ ? presetEntriesFromHeaderPreset(result.headerPresets[newPresetIndex])
458+ : [],
459+ );
460+ } catch (e) {
461+ setAnalyzeError(e instanceof Error ? e.message : "Failed to parse spec");
462+ } finally {
463+ setAnalyzing(false);
464+ }
465+ };
466+
467+ const selectPreset = (index: number) => {
468+ setPresetIndex(index);
469+ if (index === -1) {
470+ // "None" — clear everything
471+ setCustomHeaders([]);
472+ } else if (index === -2) {
473+ // "Custom" — keep user headers, drop preset-derived, seed if empty
474+ const userHeaders = customHeaders.filter((h) => !h.fromPreset);
475+ setCustomHeaders(userHeaders.length > 0 ? userHeaders : [{ name: "", secretId: null, presetKey: undefined }]);
476+ } else {
477+ // Preset strategy — replace preset-derived headers, keep user headers
478+ const preset = presets[index];
479+ const userHeaders = customHeaders.filter((h) => !h.fromPreset);
480+ setCustomHeaders(preset ? [...presetEntriesFromHeaderPreset(preset), ...userHeaders] : userHeaders);
481+ }
482+ };
483+
484+ const addCustomHeader = () => {
485+ setCustomHeaders([...customHeaders, { name: "", secretId: null, presetKey: undefined }]);
486+ };
487+
488+ const updateCustomHeader = (index: number, update: Partial<{ name: string; secretId: string | null; prefix?: string; presetKey?: string }>) => {
489+ setCustomHeaders(customHeaders.map((ch, i) => (i === index ? { ...ch, ...update } : ch)));
490+ };
491+
492+ const removeCustomHeader = (index: number) => {
493+ setCustomHeaders(customHeaders.filter((_, i) => i !== index));
494+ };
495+
496+ const handleAdd = async () => {
497+ setAdding(true);
498+ setAddError(null);
499+ try {
500+ await doAdd({
501+ path: { scopeId },
502+ payload: {
503+ spec: specUrl,
504+ baseUrl: baseUrl.trim() || undefined,
505+ ...(hasHeaders ? { headers: allHeaders } : {}),
506+ },
507+ });
508+ props.onComplete();
509+ } catch (e) {
510+ setAddError(e instanceof Error ? e.message : "Failed to add source");
511+ setAdding(false);
512+ }
513+ };
514+
515+ // ---- Render ----
516+
517+ return (
518+ <div className="space-y-6">
519+ <h1 className="text-xl font-semibold text-foreground">Add OpenAPI Source</h1>
520+
521+ {/* ── Spec input ── */}
522+ <section className="space-y-2">
523+ <Label>OpenAPI Spec</Label>
524+ <Textarea
525+ value={specUrl}
526+ onChange={(e) => {
527+ setSpecUrl((e.target as HTMLTextAreaElement).value);
528+ if (preview) {
529+ setPreview(null);
530+ setBaseUrl("");
531+ setCustomHeaders([]);
532+ }
533+ }}
534+ placeholder="https://api.example.com/openapi.json"
535+ rows={3}
536+ className="font-mono text-sm"
537+ />
538+
539+ {analyzeError && (
540+ <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2">
541+ <p className="text-[12px] text-destructive">{analyzeError}</p>
542+ </div>
543+ )}
544+
545+ {!preview && (
546+ <div className="flex items-center justify-between">
547+ <p className="text-[12px] text-muted-foreground">
548+ Paste a URL or raw JSON/YAML content.
549+ </p>
550+ <Button
551+ disabled={!specUrl.trim() || analyzing}
552+ onClick={handleAnalyze}
553+ >
554+ {analyzing && <Spinner className="size-3.5" />}
555+ {analyzing ? "Analyzing…" : "Analyze"}
556+ </Button>
557+ </div>
558+ )}
559+ </section>
560+
561+ {/* ── Everything below appears after analysis ── */}
562+ {preview && (
563+ <>
564+ {/* API info */}
565+ <div className="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3">
566+ <div className="min-w-0 flex-1">
567+ <p className="text-sm font-semibold text-card-foreground leading-none truncate">
568+ {Option.getOrElse(preview.title, () => "API")}
569+ </p>
570+ <p className="mt-1 text-[12px] text-muted-foreground leading-none">
571+ {Option.getOrElse(preview.version, () => "")}
572+ {Option.isSome(preview.version) && " · "}
573+ {preview.operationCount} operation{preview.operationCount !== 1 ? "s" : ""}
574+ {preview.tags.length > 0 && ` · ${preview.tags.length} tag${preview.tags.length !== 1 ? "s" : ""}`}
575+ </p>
576+ </div>
577+ {preview.tags.length > 0 && (
578+ <div className="hidden sm:flex flex-wrap gap-1 max-w-[200px] justify-end">
579+ {preview.tags.slice(0, 4).map((tag) => (
580+ <Badge key={tag} variant="secondary" className="text-[10px]">
581+ {tag}
582+ </Badge>
583+ ))}
584+ {preview.tags.length > 4 && (
585+ <span className="text-[10px] text-muted-foreground">+{preview.tags.length - 4}</span>
586+ )}
587+ </div>
588+ )}
589+ </div>
590+
591+ {/* Base URL */}
592+ <section className="space-y-2">
593+ <Label>Base URL</Label>
594+
595+ {servers.length > 1 ? (
596+ <div className="space-y-2">
597+ <RadioGroup
598+ value={baseUrl}
599+ onValueChange={setBaseUrl}
600+ className="gap-1.5"
601+ >
602+ {servers.map((s, i) => {
603+ const url = s.url ?? "";
604+ return (
605+ <label
606+ key={i}
607+ className={`flex items-center gap-2.5 rounded-lg border px-3 py-2 cursor-pointer transition-colors ${
608+ baseUrl === url
609+ ? "border-primary/50 bg-primary/[0.03]"
610+ : "border-border hover:bg-accent/50"
611+ }`}
612+ >
613+ <RadioGroupItem value={url} />
614+ <span className="font-mono text-xs text-foreground truncate">{url}</span>
615+ </label>
616+ );
617+ })}
618+ </RadioGroup>
619+ <Input
620+ value={baseUrl}
621+ onChange={(e) => setBaseUrl((e.target as HTMLInputElement).value)}
622+ placeholder="Or enter a custom URL…"
623+ className="font-mono text-sm"
624+ />
625+ </div>
626+ ) : (
627+ <Input
628+ value={baseUrl}
629+ onChange={(e) => setBaseUrl((e.target as HTMLInputElement).value)}
630+ placeholder="https://api.example.com"
631+ className="font-mono text-sm"
632+ />
633+ )}
634+
635+ {!baseUrl.trim() && (
636+ <p className="text-[11px] text-amber-600 dark:text-amber-400">
637+ A base URL is required to make requests.
638+ </p>
639+ )}
640+ </section>
641+
642+ {/* Authentication */}
643+ <section className="space-y-2.5">
644+ <Label>Authentication</Label>
645+
646+ {/* Strategy picker */}
647+ {hasAuth && (
648+ <RadioGroup
649+ value={String(presetIndex)}
650+ onValueChange={(v) => selectPreset(Number(v))}
651+ className="gap-1.5"
652+ >
653+ {presets.map((preset, i) => (
654+ <label
655+ key={i}
656+ className={`flex items-center gap-2.5 rounded-lg border px-3 py-2 cursor-pointer transition-colors ${
657+ presetIndex === i
658+ ? "border-primary/50 bg-primary/[0.03]"
659+ : "border-border hover:bg-accent/50"
660+ }`}
661+ >
662+ <RadioGroupItem value={String(i)} />
663+ <span className="text-xs font-medium text-foreground">{preset.label}</span>
664+ {preset.secretHeaders.length > 0 && (
665+ <span className="ml-auto text-[10px] text-muted-foreground">
666+ {preset.secretHeaders.length} header{preset.secretHeaders.length > 1 ? "s" : ""}
667+ </span>
668+ )}
669+ </label>
670+ ))}
671+
672+ <label
673+ className={`flex items-center gap-2.5 rounded-lg border px-3 py-2 cursor-pointer transition-colors ${
674+ presetIndex === -2
675+ ? "border-primary/50 bg-primary/[0.03]"
676+ : "border-border hover:bg-accent/50"
677+ }`}
678+ >
679+ <RadioGroupItem value="-2" />
680+ <span className="text-xs font-medium text-foreground">Custom</span>
681+ <span className="ml-auto text-[10px] text-muted-foreground">configure manually</span>
682+ </label>
683+
684+ <label
685+ className={`flex items-center gap-2.5 rounded-lg border px-3 py-2 cursor-pointer transition-colors ${
686+ presetIndex === -1
687+ ? "border-primary/50 bg-primary/[0.03]"
688+ : "border-border hover:bg-accent/50"
689+ }`}
690+ >
691+ <RadioGroupItem value="-1" />
692+ <span className="text-xs font-medium text-foreground">None</span>
693+ <span className="ml-auto text-[10px] text-muted-foreground">skip auth</span>
694+ </label>
695+ </RadioGroup>
696+ )}
697+
698+ {/* All headers — preset-derived and user-added (hidden when None) */}
699+ {presetIndex !== -1 && customHeaders.length > 0 && (
700+ <div className="space-y-2">
701+ {customHeaders.map((ch, i) => (
702+ <CustomHeaderRow
703+ key={i}
704+ name={ch.name}
705+ prefix={ch.prefix}
706+ presetKey={ch.presetKey}
707+ secretId={ch.secretId}
708+ onChange={(update) => updateCustomHeader(i, update)}
709+ onSelectSecret={(secretId) => updateCustomHeader(i, { secretId })}
710+ onRemove={() => removeCustomHeader(i)}
711+ existingSecrets={secretList}
712+ />
713+ ))}
714+ </div>
715+ )}
716+
717+ {(!hasAuth || presetIndex === -2) && (
718+ <Button
719+ variant="outline"
720+ size="sm"
721+ className="w-full border-dashed"
722+ onClick={addCustomHeader}
723+ >
724+ + Add header
725+ </Button>
726+ )}
727+ </section>
728+
729+ {/* Add error */}
730+ {addError && (
731+ <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2">
732+ <p className="text-[12px] text-destructive">{addError}</p>
733+ </div>
734+ )}
735+
736+ {/* Actions */}
737+ <div className="flex items-center justify-between border-t border-border pt-4">
738+ <Button variant="ghost" onClick={props.onCancel} disabled={adding}>
739+ Cancel
740+ </Button>
741+ <Button onClick={handleAdd} disabled={!canAdd || adding}>
742+ {adding && <Spinner className="size-3.5" />}
743+ {adding ? "Adding…" : "Add source"}
744+ </Button>
745+ </div>
746+ </>
747+ )}
748+
749+ {/* Cancel when no preview yet */}
750+ {!preview && (
751+ <div className="flex items-center justify-between pt-1">
752+ <Button variant="ghost" onClick={props.onCancel}>
753+ Cancel
754+ </Button>
755+ <div />
756+ </div>
757+ )}
758+ </div>
759+ );
760+}
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+12−0
@@ -0,0 +1,12 @@
1+import { OpenApiClient } from "./client";
2+
3+// ---------------------------------------------------------------------------
4+// Mutation atoms
5+// ---------------------------------------------------------------------------
6+
7+export const previewOpenApiSpec = OpenApiClient.mutation(
8+ "openapi",
9+ "previewSpec",
10+);
11+
12+export const addOpenApiSpec = OpenApiClient.mutation("openapi", "addSpec");
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+6−0
@@ -0,0 +1,6 @@
1+export { openApiSourcePlugin } from "./source-plugin";
2+export { OpenApiClient } from "./client";
3+export {
4+ previewOpenApiSpec,
5+ addOpenApiSpec,
6+} from "./atoms";
packages/plugins/openapi/src/react/source-plugin.ts+12−0
@@ -0,0 +1,12 @@
1+import { lazy } from "react";
2+import type { SourcePlugin } from "@executor/react";
3+import { openApiPresets } from "../sdk/presets";
4+
5+export const openApiSourcePlugin: SourcePlugin = {
6+ key: "openapi",
7+ label: "OpenAPI",
8+ add: lazy(() => import("./AddOpenApiSource")),
9+ edit: lazy(() => import("./EditOpenApiSource")),
10+ summary: lazy(() => import("./OpenApiSourceSummary")),
11+ presets: openApiPresets,
12+};
packages/plugins/openapi/src/sdk/definitions.ts+255−0
@@ -0,0 +1,255 @@
1+/**
2+ * Derives structured `group.leaf` tool paths from extracted OpenAPI operations.
3+ *
4+ * Ported from the v3 executor's `definitions.ts`. Turns flat operation IDs like
5+ * `zones_listZones` into nested paths like `zones.listZones` that the tree UI
6+ * can render with proper nesting.
7+ */
8+
9+import type { ExtractedOperation } from "./types";
10+
11+// ---------------------------------------------------------------------------
12+// Word / case utilities
13+// ---------------------------------------------------------------------------
14+
15+const splitWords = (value: string): string[] =>
16+ value
17+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
18+ .replace(/([A-Z]+)([A-Z][a-z0-9]+)/g, "$1 $2")
19+ .replace(/[^a-zA-Z0-9]+/g, " ")
20+ .trim()
21+ .split(/\s+/)
22+ .filter((part) => part.length > 0);
23+
24+const normalizeWord = (value: string): string => value.toLowerCase();
25+
26+const toCamelCase = (value: string): string => {
27+ const words = splitWords(value).map(normalizeWord);
28+ if (words.length === 0) return "tool";
29+ const [first, ...rest] = words;
30+ return `${first}${rest.map((p) => `${p[0]?.toUpperCase() ?? ""}${p.slice(1)}`).join("")}`;
31+};
32+
33+const toPascalCase = (value: string): string => {
34+ const camel = toCamelCase(value);
35+ return `${camel[0]?.toUpperCase() ?? ""}${camel.slice(1)}`;
36+};
37+
38+// ---------------------------------------------------------------------------
39+// Path utilities
40+// ---------------------------------------------------------------------------
41+
42+const VERSION_SEGMENT_REGEX = /^v\d+(?:[._-]\d+)?$/i;
43+const IGNORED_PATH_SEGMENTS = new Set(["api"]);
44+
45+const pathSegmentsFromTemplate = (pathTemplate: string): string[] =>
46+ pathTemplate
47+ .split("/")
48+ .map((s) => s.trim())
49+ .filter((s) => s.length > 0);
50+
51+const isPathParameterSegment = (segment: string): boolean =>
52+ segment.startsWith("{") && segment.endsWith("}");
53+
54+const normalizeGroupSegment = (value: string | undefined): string | null => {
55+ const candidate = value?.trim();
56+ if (!candidate) return null;
57+ return toCamelCase(candidate);
58+};
59+
60+// ---------------------------------------------------------------------------
61+// Derivation
62+// ---------------------------------------------------------------------------
63+
64+const deriveVersionSegment = (pathTemplate: string): string | undefined =>
65+ pathSegmentsFromTemplate(pathTemplate)
66+ .map((s) => s.toLowerCase())
67+ .find((s) => VERSION_SEGMENT_REGEX.test(s));
68+
69+const derivePathGroup = (pathTemplate: string): string => {
70+ for (const segment of pathSegmentsFromTemplate(pathTemplate)) {
71+ const lower = segment.toLowerCase();
72+ if (VERSION_SEGMENT_REGEX.test(lower)) continue;
73+ if (IGNORED_PATH_SEGMENTS.has(lower)) continue;
74+ if (isPathParameterSegment(segment)) continue;
75+ return normalizeGroupSegment(segment) ?? "root";
76+ }
77+ return "root";
78+};
79+
80+const splitOperationIdSegments = (value: string): string[] =>
81+ value
82+ .split(/[/.]+/)
83+ .map((s) => s.trim())
84+ .filter((s) => s.length > 0);
85+
86+const deriveLeafSeed = (
87+ operationId: string,
88+ group: string,
89+): string => {
90+ const segments = splitOperationIdSegments(operationId);
91+ if (segments.length > 1) {
92+ const [first, ...rest] = segments;
93+ if ((normalizeGroupSegment(first) ?? first) === group && rest.length > 0) {
94+ return rest.join(" ");
95+ }
96+ }
97+ return operationId;
98+};
99+
100+const fallbackLeafSeed = (
101+ method: string,
102+ pathTemplate: string,
103+ group: string,
104+): string => {
105+ const relevantSegments = pathSegmentsFromTemplate(pathTemplate)
106+ .filter((s) => !VERSION_SEGMENT_REGEX.test(s.toLowerCase()))
107+ .filter((s) => !IGNORED_PATH_SEGMENTS.has(s.toLowerCase()))
108+ .filter((s) => !isPathParameterSegment(s))
109+ .map((s) => normalizeGroupSegment(s) ?? s)
110+ .filter((s) => s !== group);
111+
112+ const segmentSuffix = relevantSegments.map((s) => toPascalCase(s)).join("");
113+ return `${method}${segmentSuffix || "Operation"}`;
114+};
115+
116+const deriveLeaf = (
117+ operationId: string,
118+ method: string,
119+ pathTemplate: string,
120+ group: string,
121+): string => {
122+ const preferred = toCamelCase(deriveLeafSeed(operationId, group));
123+ if (preferred.length > 0 && preferred !== group) return preferred;
124+ return toCamelCase(fallbackLeafSeed(method, pathTemplate, group));
125+};
126+
127+// ---------------------------------------------------------------------------
128+// Public types
129+// ---------------------------------------------------------------------------
130+
131+export interface ToolDefinition {
132+ /** Dot-separated path like `zones.listZones` */
133+ readonly toolPath: string;
134+ /** The group segment */
135+ readonly group: string;
136+ /** The leaf segment */
137+ readonly leaf: string;
138+ /** Index into the original operations array */
139+ readonly operationIndex: number;
140+ /** The original operation */
141+ readonly operation: ExtractedOperation;
142+}
143+
144+// ---------------------------------------------------------------------------
145+// Collision resolution
146+// ---------------------------------------------------------------------------
147+
148+const resolveCollisions = (
149+ definitions: { toolPath: string; group: string; leaf: string; versionSegment: string | undefined; method: string; operationHash: string; operationIndex: number; operation: ExtractedOperation }[],
150+): ToolDefinition[] => {
151+ // Mutable — we progressively refine toolPath on collision
152+ const staged = definitions.map((d) => ({ ...d }));
153+
154+ const applyFactory = (
155+ items: typeof staged,
156+ factory: (d: (typeof staged)[number]) => string,
157+ ) => {
158+ const byPath = new Map<string, typeof staged>();
159+ for (const item of items) {
160+ const bucket = byPath.get(item.toolPath) ?? [];
161+ bucket.push(item);
162+ byPath.set(item.toolPath, bucket);
163+ }
164+ for (const bucket of byPath.values()) {
165+ if (bucket.length < 2) continue;
166+ for (const d of bucket) {
167+ d.toolPath = factory(d);
168+ }
169+ }
170+ };
171+
172+ // Round 1: add version segment
173+ applyFactory(staged, (d) =>
174+ d.versionSegment
175+ ? `${d.group}.${d.versionSegment}.${d.leaf}`
176+ : d.toolPath,
177+ );
178+
179+ // Round 2: add method suffix
180+ applyFactory(staged, (d) => {
181+ const prefix = d.versionSegment
182+ ? `${d.group}.${d.versionSegment}`
183+ : d.group;
184+ return `${prefix}.${d.leaf}${toPascalCase(d.method)}`;
185+ });
186+
187+ // Round 3: add hash suffix
188+ applyFactory(staged, (d) => {
189+ const prefix = d.versionSegment
190+ ? `${d.group}.${d.versionSegment}`
191+ : d.group;
192+ return `${prefix}.${d.leaf}${toPascalCase(d.method)}${d.operationHash.slice(0, 8)}`;
193+ });
194+
195+ return staged.map((d) => ({
196+ toolPath: d.toolPath,
197+ group: d.group,
198+ leaf: d.leaf,
199+ operationIndex: d.operationIndex,
200+ operation: d.operation,
201+ }));
202+};
203+
204+// ---------------------------------------------------------------------------
205+// Stable hash (simple, deterministic)
206+// ---------------------------------------------------------------------------
207+
208+const stableHash = (value: unknown): string => {
209+ const str = JSON.stringify(value, Object.keys(value as Record<string, unknown>).sort());
210+ let hash = 0;
211+ for (let i = 0; i < str.length; i++) {
212+ hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
213+ }
214+ return Math.abs(hash).toString(36).padStart(8, "0");
215+};
216+
217+// ---------------------------------------------------------------------------
218+// Main entry point
219+// ---------------------------------------------------------------------------
220+
221+/**
222+ * Compile extracted operations into tool definitions with structured
223+ * `group.leaf` paths suitable for tree rendering.
224+ */
225+export const compileToolDefinitions = (
226+ operations: readonly ExtractedOperation[],
227+): ToolDefinition[] => {
228+ const raw = operations.map((op, index) => {
229+ const operationId = op.operationId as string;
230+ const group =
231+ normalizeGroupSegment(op.tags[0]) ?? derivePathGroup(op.pathTemplate);
232+ const leaf = deriveLeaf(operationId, op.method, op.pathTemplate, group);
233+ const versionSegment = deriveVersionSegment(op.pathTemplate);
234+ const operationHash = stableHash({
235+ method: op.method,
236+ path: op.pathTemplate,
237+ operationId,
238+ });
239+
240+ return {
241+ toolPath: `${group}.${leaf}`,
242+ group,
243+ leaf,
244+ versionSegment,
245+ method: op.method,
246+ operationHash,
247+ operationIndex: index,
248+ operation: op,
249+ };
250+ });
251+
252+ return resolveCollisions(raw).sort((a, b) =>
253+ a.toolPath.localeCompare(b.toolPath),
254+ );
255+};
packages/plugins/openapi/src/sdk/errors.ts+25−0
@@ -0,0 +1,25 @@
1+import { Schema } from "effect";
2+
3+export class OpenApiParseError extends Schema.TaggedError<OpenApiParseError>()(
4+ "OpenApiParseError",
5+ {
6+ message: Schema.String,
7+ error: Schema.Defect,
8+ },
9+) {}
10+
11+export class OpenApiExtractionError extends Schema.TaggedError<OpenApiExtractionError>()(
12+ "OpenApiExtractionError",
13+ {
14+ message: Schema.String,
15+ },
16+) {}
17+
18+export class OpenApiInvocationError extends Schema.TaggedError<OpenApiInvocationError>()(
19+ "OpenApiInvocationError",
20+ {
21+ message: Schema.String,
22+ statusCode: Schema.optionalWith(Schema.Number, { as: "Option" }),
23+ error: Schema.Defect,
24+ },
25+) {}
packages/plugins/openapi/src/sdk/extract.ts+255−0
@@ -0,0 +1,255 @@
1+import { Effect, Option } from "effect";
2+
3+import { OpenApiExtractionError } from "./errors";
4+import type { ParsedDocument } from "./parse";
5+import {
6+ DocResolver,
7+ preferredContent,
8+ type OperationObject,
9+ type ParameterObject,
10+ type PathItemObject,
11+ type RequestBodyObject,
12+ type ResponseObject,
13+} from "./openapi-utils";
14+import {
15+ ExtractedOperation,
16+ ExtractionResult,
17+ type HttpMethod,
18+ OperationId,
19+ OperationParameter,
20+ OperationRequestBody,
21+ type ParameterLocation,
22+ ServerInfo,
23+} from "./types";
24+
25+// ---------------------------------------------------------------------------
26+// Constants
27+// ---------------------------------------------------------------------------
28+
29+const HTTP_METHODS: readonly HttpMethod[] = [
30+ "get", "put", "post", "delete", "patch", "head", "options", "trace",
31+];
32+
33+const VALID_PARAM_LOCATIONS = new Set<string>(["path", "query", "header", "cookie"]);
34+
35+// ---------------------------------------------------------------------------
36+// Parameter extraction
37+// ---------------------------------------------------------------------------
38+
39+const extractParameters = (
40+ pathItem: PathItemObject,
41+ operation: OperationObject,
42+ r: DocResolver,
43+): OperationParameter[] => {
44+ const merged = new Map<string, ParameterObject>();
45+
46+ for (const raw of pathItem.parameters ?? []) {
47+ const p = r.resolve<ParameterObject>(raw);
48+ if (!p) continue;
49+ merged.set(`${p.in}:${p.name}`, p);
50+ }
51+ for (const raw of operation.parameters ?? []) {
52+ const p = r.resolve<ParameterObject>(raw);
53+ if (!p) continue;
54+ merged.set(`${p.in}:${p.name}`, p);
55+ }
56+
57+ return [...merged.values()]
58+ .filter((p) => VALID_PARAM_LOCATIONS.has(p.in))
59+ .map(
60+ (p) =>
61+ new OperationParameter({
62+ name: p.name,
63+ location: p.in as ParameterLocation,
64+ required: p.in === "path" ? true : p.required === true,
65+ schema: Option.fromNullable(p.schema),
66+ style: Option.fromNullable(p.style),
67+ explode: Option.fromNullable(p.explode),
68+ allowReserved: Option.fromNullable(
69+ "allowReserved" in p ? p.allowReserved : undefined,
70+ ),
71+ description: Option.fromNullable(p.description),
72+ }),
73+ );
74+};
75+
76+// ---------------------------------------------------------------------------
77+// Request body extraction
78+// ---------------------------------------------------------------------------
79+
80+const extractRequestBody = (
81+ operation: OperationObject,
82+ r: DocResolver,
83+): OperationRequestBody | undefined => {
84+ if (!operation.requestBody) return undefined;
85+
86+ const body = r.resolve<RequestBodyObject>(operation.requestBody);
87+ if (!body) return undefined;
88+
89+ const content = preferredContent(body.content);
90+ if (!content) return undefined;
91+
92+ return new OperationRequestBody({
93+ required: body.required === true,
94+ contentType: content.mediaType,
95+ schema: Option.fromNullable(content.media.schema),
96+ });
97+};
98+
99+// ---------------------------------------------------------------------------
100+// Response schema extraction
101+// ---------------------------------------------------------------------------
102+
103+const extractOutputSchema = (
104+ operation: OperationObject,
105+ r: DocResolver,
106+): unknown | undefined => {
107+ if (!operation.responses) return undefined;
108+
109+ const entries = Object.entries(operation.responses);
110+ const preferred = [
111+ ...entries.filter(([s]) => /^2\d\d$/.test(s)).sort(([a], [b]) => a.localeCompare(b)),
112+ ...entries.filter(([s]) => s === "default"),
113+ ];
114+
115+ for (const [, ref] of preferred) {
116+ const resp = r.resolve<ResponseObject>(ref);
117+ if (!resp) continue;
118+ const content = preferredContent(resp.content);
119+ if (content?.media.schema) return content.media.schema;
120+ }
121+
122+ return undefined;
123+};
124+
125+// ---------------------------------------------------------------------------
126+// Input schema builder
127+// ---------------------------------------------------------------------------
128+
129+const buildInputSchema = (
130+ parameters: readonly OperationParameter[],
131+ requestBody: OperationRequestBody | undefined,
132+): Record<string, unknown> | undefined => {
133+ const properties: Record<string, unknown> = {};
134+ const required: string[] = [];
135+
136+ for (const param of parameters) {
137+ properties[param.name] = Option.getOrElse(param.schema, () => ({ type: "string" }));
138+ if (param.required) required.push(param.name);
139+ }
140+
141+ if (requestBody) {
142+ properties.body = Option.getOrElse(requestBody.schema, () => ({ type: "object" }));
143+ if (requestBody.required) required.push("body");
144+ }
145+
146+ if (Object.keys(properties).length === 0) return undefined;
147+
148+ return {
149+ type: "object",
150+ properties,
151+ ...(required.length > 0 ? { required } : {}),
152+ additionalProperties: false,
153+ };
154+};
155+
156+// ---------------------------------------------------------------------------
157+// Operation ID derivation
158+// ---------------------------------------------------------------------------
159+
160+const deriveOperationId = (
161+ method: HttpMethod,
162+ pathTemplate: string,
163+ operation: OperationObject,
164+): string =>
165+ operation.operationId ??
166+ (`${method}_${pathTemplate.replace(/[^a-zA-Z0-9]+/g, "_")}`.replace(
167+ /^_+|_+$/g,
168+ "",
169+ ) || `${method}_operation`);
170+
171+// ---------------------------------------------------------------------------
172+// Server extraction
173+// ---------------------------------------------------------------------------
174+
175+const extractServers = (doc: ParsedDocument): ServerInfo[] =>
176+ (doc.servers ?? []).flatMap((server) => {
177+ if (!server.url) return [];
178+ const vars = server.variables
179+ ? Object.fromEntries(
180+ Object.entries(server.variables).flatMap(([name, v]) =>
181+ v.default ? [[name, v.default]] : [],
182+ ),
183+ )
184+ : undefined;
185+ return [
186+ new ServerInfo({
187+ url: server.url,
188+ variables:
189+ vars && Object.keys(vars).length > 0
190+ ? Option.some(vars)
191+ : Option.none(),
192+ }),
193+ ];
194+ });
195+
196+// ---------------------------------------------------------------------------
197+// Main extraction
198+// ---------------------------------------------------------------------------
199+
200+/** Extract all operations from a bundled OpenAPI 3.x document */
201+export const extract = Effect.fn("OpenApi.extract")(function* (
202+ doc: ParsedDocument,
203+) {
204+ const paths = doc.paths;
205+ if (!paths) {
206+ return yield* new OpenApiExtractionError({
207+ message: "OpenAPI document has no paths defined",
208+ });
209+ }
210+
211+ const r = new DocResolver(doc);
212+ const operations: ExtractedOperation[] = [];
213+
214+ for (const [pathTemplate, pathItem] of Object.entries(paths).sort(
215+ ([a], [b]) => a.localeCompare(b),
216+ )) {
217+ if (!pathItem) continue;
218+
219+ for (const method of HTTP_METHODS) {
220+ const operation = pathItem[method];
221+ if (!operation) continue;
222+
223+ const parameters = extractParameters(pathItem, operation, r);
224+ const requestBody = extractRequestBody(operation, r);
225+ const inputSchema = buildInputSchema(parameters, requestBody);
226+ const outputSchema = extractOutputSchema(operation, r);
227+ const tags = (operation.tags ?? []).filter((t) => t.trim().length > 0);
228+
229+ operations.push(
230+ new ExtractedOperation({
231+ operationId: OperationId.make(
232+ deriveOperationId(method, pathTemplate, operation),
233+ ),
234+ method,
235+ pathTemplate,
236+ summary: Option.fromNullable(operation.summary),
237+ description: Option.fromNullable(operation.description),
238+ tags,
239+ parameters,
240+ requestBody: Option.fromNullable(requestBody),
241+ inputSchema: Option.fromNullable(inputSchema),
242+ outputSchema: Option.fromNullable(outputSchema),
243+ deprecated: operation.deprecated === true,
244+ }),
245+ );
246+ }
247+ }
248+
249+ return new ExtractionResult({
250+ title: Option.fromNullable(doc.info?.title),
251+ version: Option.fromNullable(doc.info?.version),
252+ servers: extractServers(doc),
253+ operations,
254+ });
255+});
packages/plugins/openapi/src/sdk/index.test.ts+181−0
@@ -0,0 +1,181 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import {
3+ HttpApi,
4+ HttpApiEndpoint,
5+ HttpApiGroup,
6+ OpenApi,
7+} from "@effect/platform";
8+import { Effect, Option, Schema } from "effect";
9+
10+import { parse } from "./parse";
11+import { extract } from "./extract";
12+import { compileToolDefinitions } from "./definitions";
13+
14+// ---------------------------------------------------------------------------
15+// Define a test API using Effect's HttpApi
16+// ---------------------------------------------------------------------------
17+
18+class Pet extends Schema.Class<Pet>("Pet")({
19+ id: Schema.Number,
20+ name: Schema.String,
21+ tag: Schema.optional(Schema.String),
22+}) {}
23+
24+class CreatePetInput extends Schema.Class<CreatePetInput>("CreatePetInput")({
25+ name: Schema.String,
26+ tag: Schema.optional(Schema.String),
27+}) {}
28+
29+class PetNotFound extends Schema.TaggedError<PetNotFound>()("PetNotFound", {
30+ message: Schema.String,
31+}) {}
32+
33+const PetstoreGroup = HttpApiGroup.make("pets")
34+ .add(
35+ HttpApiEndpoint.get("listPets", "/pets")
36+ .addSuccess(Schema.Array(Pet)),
37+ )
38+ .add(
39+ HttpApiEndpoint.post("createPet", "/pets")
40+ .setPayload(CreatePetInput)
41+ .addSuccess(Pet),
42+ )
43+ .add(
44+ HttpApiEndpoint.get("getPet", "/pets/:petId")
45+ .addSuccess(Pet)
46+ .addError(PetNotFound, { status: 404 }),
47+ );
48+
49+const PetstoreApi = HttpApi.make("petstore").add(PetstoreGroup);
50+
51+// Generate OpenAPI spec from the Effect API definition
52+const spec = OpenApi.fromApi(PetstoreApi);
53+
54+// ---------------------------------------------------------------------------
55+// Tests
56+// ---------------------------------------------------------------------------
57+
58+describe("OpenAPI plugin", () => {
59+ it.effect("parses and extracts operations from Effect HttpApi spec", () =>
60+ Effect.gen(function* () {
61+ const doc = yield* parse(JSON.stringify(spec));
62+ const result = yield* extract(doc);
63+
64+ expect(Option.getOrElse(result.title, () => "")).toBe("Api");
65+ expect(result.operations.length).toBeGreaterThanOrEqual(3);
66+ }),
67+ );
68+
69+ it.effect("extracts listPets with output schema", () =>
70+ Effect.gen(function* () {
71+ const doc = yield* parse(JSON.stringify(spec));
72+ const result = yield* extract(doc);
73+
74+ const listPets = result.operations.find(
75+ (op) => op.operationId === "pets.listPets",
76+ );
77+ expect(listPets).toBeDefined();
78+ expect(listPets!.method).toBe("get");
79+ expect(listPets!.pathTemplate).toBe("/pets");
80+ expect(listPets!.tags).toContain("pets");
81+
82+ // Has output schema (array of pets, dereferenced)
83+ expect(Option.isSome(listPets!.outputSchema)).toBe(true);
84+ const outputSchema = Option.getOrThrow(listPets!.outputSchema) as Record<
85+ string,
86+ unknown
87+ >;
88+ expect(outputSchema.type).toBe("array");
89+ }),
90+ );
91+
92+ it.effect("extracts createPet with request body from payload", () =>
93+ Effect.gen(function* () {
94+ const doc = yield* parse(JSON.stringify(spec));
95+ const result = yield* extract(doc);
96+
97+ const createPet = result.operations.find(
98+ (op) => op.operationId === "pets.createPet",
99+ );
100+ expect(createPet).toBeDefined();
101+ expect(createPet!.method).toBe("post");
102+
103+ // Has request body
104+ expect(Option.isSome(createPet!.requestBody)).toBe(true);
105+ const rb = Option.getOrThrow(createPet!.requestBody);
106+ expect(rb.contentType).toBe("application/json");
107+
108+ // Input schema includes body
109+ expect(Option.isSome(createPet!.inputSchema)).toBe(true);
110+ const inputSchema = Option.getOrThrow(
111+ createPet!.inputSchema,
112+ ) as Record<string, unknown>;
113+ expect(inputSchema.properties).toHaveProperty("body");
114+ }),
115+ );
116+
117+ it.effect("extracts getPet with path template", () =>
118+ Effect.gen(function* () {
119+ const doc = yield* parse(JSON.stringify(spec));
120+ const result = yield* extract(doc);
121+
122+ const getPet = result.operations.find(
123+ (op) => op.operationId === "pets.getPet",
124+ );
125+ expect(getPet).toBeDefined();
126+ expect(getPet!.method).toBe("get");
127+ expect(getPet!.pathTemplate).toBe("/pets/{petId}");
128+ }),
129+ );
130+
131+ it.effect("extracts error responses", () =>
132+ Effect.gen(function* () {
133+ const doc = yield* parse(JSON.stringify(spec));
134+ const result = yield* extract(doc);
135+
136+ const getPet = result.operations.find(
137+ (op) => op.operationId === "pets.getPet",
138+ );
139+ expect(getPet).toBeDefined();
140+
141+ // Should have a success output schema
142+ expect(Option.isSome(getPet!.outputSchema)).toBe(true);
143+ }),
144+ );
145+
146+ it.effect("round-trips: generated spec paths match HttpApi definition", () =>
147+ Effect.gen(function* () {
148+ const doc = yield* parse(JSON.stringify(spec));
149+ const result = yield* extract(doc);
150+
151+ // Should have all 3 operations
152+ const paths = result.operations.map((op) => `${op.method} ${op.pathTemplate}`);
153+ expect(paths).toContain("get /pets");
154+ expect(paths).toContain("post /pets");
155+ expect(paths).toContain("get /pets/{petId}");
156+ }),
157+ );
158+
159+ it.effect("compileToolDefinitions produces nested group.leaf paths", () =>
160+ Effect.gen(function* () {
161+ const doc = yield* parse(JSON.stringify(spec));
162+ const result = yield* extract(doc);
163+ const defs = compileToolDefinitions(result.operations);
164+
165+ // All 3 operations compiled
166+ expect(defs).toHaveLength(3);
167+
168+ // All should be under the "pets" group
169+ for (const def of defs) {
170+ expect(def.group).toBe("pets");
171+ expect(def.toolPath).toMatch(/^pets\./);
172+ }
173+
174+ // Specific tool paths
175+ const paths = defs.map((d) => d.toolPath);
176+ expect(paths).toContain("pets.listPets");
177+ expect(paths).toContain("pets.createPet");
178+ expect(paths).toContain("pets.getPet");
179+ }),
180+ );
181+});
packages/plugins/openapi/src/sdk/index.ts+45−0
@@ -0,0 +1,45 @@
1+export { parse } from "./parse";
2+export { extract } from "./extract";
3+export { invoke, makeOpenApiInvoker } from "./invoke";
4+export {
5+ openApiPlugin,
6+ type OpenApiSpecConfig,
7+ type OpenApiPluginExtension,
8+} from "./plugin";
9+export {
10+ type OpenApiOperationStore,
11+ type StoredSource,
12+ type SourceConfig,
13+} from "./operation-store";
14+export {
15+ makeKvOperationStore,
16+ makeInMemoryOperationStore,
17+} from "./kv-operation-store";
18+export {
19+ previewSpec,
20+ SecurityScheme,
21+ AuthStrategy,
22+ HeaderPreset,
23+ SpecPreview,
24+} from "./preview";
25+export { DocResolver, resolveBaseUrl, preferredContent } from "./openapi-utils";
26+
27+export {
28+ OpenApiParseError,
29+ OpenApiExtractionError,
30+ OpenApiInvocationError,
31+} from "./errors";
32+
33+export {
34+ ExtractedOperation,
35+ ExtractionResult,
36+ InvocationConfig,
37+ InvocationResult,
38+ OperationBinding,
39+ OperationParameter,
40+ OperationRequestBody,
41+ ServerInfo,
42+ OperationId,
43+ HttpMethod,
44+ ParameterLocation,
45+} from "./types";
packages/plugins/openapi/src/sdk/invoke.ts+374−0
@@ -0,0 +1,374 @@
1+import { Effect, Layer, Option } from "effect";
2+import {
3+ HttpClient,
4+ HttpClientRequest,
5+} from "@effect/platform";
6+
7+import {
8+ type ToolId,
9+ type ToolInvoker,
10+ ToolInvocationResult,
11+ ToolInvocationError,
12+ type ScopeId,
13+ type SecretId,
14+} from "@executor/sdk";
15+
16+import { OpenApiInvocationError } from "./errors";
17+import type { OpenApiOperationStore } from "./operation-store";
18+import {
19+ type HeaderValue,
20+ type OperationBinding,
21+ InvocationConfig,
22+ InvocationResult,
23+ type OperationParameter,
24+} from "./types";
25+
26+// ---------------------------------------------------------------------------
27+// Parameter reading
28+// ---------------------------------------------------------------------------
29+
30+const CONTAINER_KEYS: Record<string, readonly string[]> = {
31+ path: ["path", "pathParams", "params"],
32+ query: ["query", "queryParams", "params"],
33+ header: ["headers", "header"],
34+ cookie: ["cookies", "cookie"],
35+};
36+
37+const readParamValue = (
38+ args: Record<string, unknown>,
39+ param: OperationParameter,
40+): unknown => {
41+ const direct = args[param.name];
42+ if (direct !== undefined) return direct;
43+
44+ for (const key of CONTAINER_KEYS[param.location] ?? []) {
45+ const container = args[key];
46+ if (
47+ typeof container === "object" &&
48+ container !== null &&
49+ !Array.isArray(container)
50+ ) {
51+ const nested = (container as Record<string, unknown>)[param.name];
52+ if (nested !== undefined) return nested;
53+ }
54+ }
55+
56+ return undefined;
57+};
58+
59+// ---------------------------------------------------------------------------
60+// Path resolution
61+// ---------------------------------------------------------------------------
62+
63+const resolvePath = Effect.fn("OpenApi.resolvePath")(function* (
64+ pathTemplate: string,
65+ args: Record<string, unknown>,
66+ parameters: readonly OperationParameter[],
67+) {
68+ let resolved = pathTemplate;
69+
70+ // Resolve declared path parameters
71+ for (const param of parameters) {
72+ if (param.location !== "path") continue;
73+ const value = readParamValue(args, param);
74+ if (value === undefined || value === null) {
75+ if (param.required) {
76+ return yield* new OpenApiInvocationError({
77+ message: `Missing required path parameter: ${param.name}`,
78+ statusCode: Option.none(),
79+ error: undefined,
80+ });
81+ }
82+ continue;
83+ }
84+ resolved = resolved.replaceAll(
85+ `{${param.name}}`,
86+ encodeURIComponent(String(value)),
87+ );
88+ }
89+
90+ // Resolve remaining placeholders from raw args (handles specs that
91+ // don't explicitly list path parameters)
92+ const remaining = [...resolved.matchAll(/\{([^{}]+)\}/g)]
93+ .map((m) => m[1])
94+ .filter((v): v is string => typeof v === "string");
95+
96+ for (const name of remaining) {
97+ const value = args[name];
98+ if (value !== undefined && value !== null) {
99+ resolved = resolved.replaceAll(
100+ `{${name}}`,
101+ encodeURIComponent(String(value)),
102+ );
103+ }
104+ }
105+
106+ const unresolved = [...resolved.matchAll(/\{([^{}]+)\}/g)]
107+ .map((m) => m[1])
108+ .filter((v): v is string => typeof v === "string");
109+
110+ if (unresolved.length > 0) {
111+ return yield* new OpenApiInvocationError({
112+ message: `Unresolved path parameters: ${[...new Set(unresolved)].join(", ")}`,
113+ statusCode: Option.none(),
114+ error: undefined,
115+ });
116+ }
117+
118+ return resolved;
119+});
120+
121+// ---------------------------------------------------------------------------
122+// Header resolution — resolves secret refs at invocation time
123+// ---------------------------------------------------------------------------
124+
125+const resolveHeaders = (
126+ headers: Record<string, HeaderValue>,
127+ secrets: { readonly resolve: (secretId: SecretId, scopeId: ScopeId) => Effect.Effect<string, unknown> },
128+ scopeId: ScopeId,
129+): Effect.Effect<Record<string, string>, ToolInvocationError> =>
130+ Effect.gen(function* () {
131+ const resolved: Record<string, string> = {};
132+ for (const [name, value] of Object.entries(headers)) {
133+ if (typeof value === "string") {
134+ resolved[name] = value;
135+ } else {
136+ const secret = yield* secrets.resolve(value.secretId as SecretId, scopeId).pipe(
137+ Effect.mapError(() =>
138+ new ToolInvocationError({
139+ toolId: "" as ToolId,
140+ message: `Failed to resolve secret "${value.secretId}" for header "${name}"`,
141+ cause: undefined,
142+ }),
143+ ),
144+ );
145+ resolved[name] = value.prefix ? `${value.prefix}${secret}` : secret;
146+ }
147+ }
148+ return resolved;
149+ });
150+
151+const applyHeaders = (
152+ request: HttpClientRequest.HttpClientRequest,
153+ headers: Record<string, string>,
154+): HttpClientRequest.HttpClientRequest => {
155+ let req = request;
156+ for (const [name, value] of Object.entries(headers)) {
157+ req = HttpClientRequest.setHeader(req, name, value);
158+ }
159+ return req;
160+};
161+
162+// ---------------------------------------------------------------------------
163+// Response helpers
164+// ---------------------------------------------------------------------------
165+
166+const isJsonContentType = (ct: string | null | undefined): boolean => {
167+ if (!ct) return false;
168+ const normalized = ct.split(";")[0]?.trim().toLowerCase() ?? "";
169+ return (
170+ normalized === "application/json" ||
171+ normalized.includes("+json") ||
172+ normalized.includes("json")
173+ );
174+};
175+
176+// ---------------------------------------------------------------------------
177+// Public API
178+// ---------------------------------------------------------------------------
179+
180+/** Invoke an OpenAPI operation binding. Requires HttpClient in the context. */
181+export const invoke = Effect.fn("OpenApi.invoke")(function* (
182+ operation: OperationBinding,
183+ args: Record<string, unknown>,
184+ config: InvocationConfig,
185+ /** Pre-resolved headers (secrets already resolved) */
186+ resolvedHeaders?: Record<string, string>,
187+) {
188+ const client = yield* HttpClient.HttpClient;
189+
190+ const resolvedPath = yield* resolvePath(
191+ operation.pathTemplate,
192+ args,
193+ operation.parameters,
194+ );
195+
196+ const path = resolvedPath.startsWith("/") ? resolvedPath : `/${resolvedPath}`;
197+
198+ // Build the base request — use just the path; baseUrl is applied to the client
199+ let request = HttpClientRequest.make(operation.method.toUpperCase() as "GET")(path);
200+
201+ // Query parameters
202+ for (const param of operation.parameters) {
203+ if (param.location !== "query") continue;
204+ const value = readParamValue(args, param);
205+ if (value === undefined || value === null) continue;
206+ request = HttpClientRequest.setUrlParam(
207+ request,
208+ param.name,
209+ String(value),
210+ );
211+ }
212+
213+ // Header parameters
214+ for (const param of operation.parameters) {
215+ if (param.location !== "header") continue;
216+ const value = readParamValue(args, param);
217+ if (value === undefined || value === null) continue;
218+ request = HttpClientRequest.setHeader(
219+ request,
220+ param.name,
221+ String(value),
222+ );
223+ }
224+
225+ // Request body
226+ if (Option.isSome(operation.requestBody)) {
227+ const rb = operation.requestBody.value;
228+ const bodyValue = args.body ?? args.input;
229+ if (bodyValue !== undefined) {
230+ if (isJsonContentType(rb.contentType)) {
231+ request = HttpClientRequest.bodyUnsafeJson(request, bodyValue);
232+ } else {
233+ request = HttpClientRequest.bodyText(request, String(bodyValue), rb.contentType);
234+ }
235+ }
236+ }
237+
238+ // Static headers (auth, custom headers, etc.) — use pre-resolved if available
239+ request = applyHeaders(request, resolvedHeaders ?? {});
240+
241+ // Execute
242+ const response = yield* client.execute(request).pipe(
243+ Effect.mapError(
244+ (err) =>
245+ new OpenApiInvocationError({
246+ message: `HTTP request failed: ${err.message}`,
247+ statusCode: Option.none(),
248+ error: err,
249+ }),
250+ ),
251+ );
252+
253+ const status = response.status;
254+ const responseHeaders: Record<string, string> = { ...response.headers };
255+
256+ // Decode body
257+ const contentType = response.headers["content-type"] ?? null;
258+ const responseBody: unknown =
259+ status === 204
260+ ? null
261+ : isJsonContentType(contentType)
262+ ? yield* response.json.pipe(
263+ Effect.catchAll(() => response.text),
264+ )
265+ : yield* response.text;
266+
267+ const ok = status >= 200 && status < 300;
268+
269+ return new InvocationResult({
270+ status,
271+ headers: responseHeaders,
272+ data: ok ? responseBody : null,
273+ error: ok ? null : responseBody,
274+ });
275+});
276+
277+// ---------------------------------------------------------------------------
278+// ToolInvoker — bridges operation store + HTTP client into SDK invoker
279+// ---------------------------------------------------------------------------
280+
281+const SAFE_METHODS = new Set(["get", "head", "options"]);
282+
283+/**
284+ * Derive tool annotations from the HTTP method and path.
285+ */
286+export const annotationsForOperation = (method: string, pathTemplate: string): {
287+ requiresApproval?: boolean;
288+ approvalDescription?: string;
289+} => {
290+ if (SAFE_METHODS.has(method.toLowerCase())) return {};
291+ return {
292+ requiresApproval: true,
293+ approvalDescription: `${method.toUpperCase()} ${pathTemplate}`,
294+ };
295+};
296+
297+export const makeOpenApiInvoker = (opts: {
298+ readonly operationStore: OpenApiOperationStore;
299+ readonly httpClientLayer: Layer.Layer<HttpClient.HttpClient>;
300+ readonly secrets: { readonly resolve: (secretId: SecretId, scopeId: ScopeId) => Effect.Effect<string, unknown> };
301+ readonly scopeId: ScopeId;
302+}): ToolInvoker => ({
303+ resolveAnnotations: (toolId: ToolId) =>
304+ Effect.gen(function* () {
305+ const entry = yield* opts.operationStore.get(toolId);
306+ if (!entry) return undefined;
307+ return annotationsForOperation(entry.binding.method, entry.binding.pathTemplate);
308+ }),
309+
310+ invoke: (toolId: ToolId, args: unknown) =>
311+ Effect.gen(function* () {
312+ const entry = yield* opts.operationStore.get(toolId);
313+ if (!entry) {
314+ return yield* new ToolInvocationError({
315+ toolId,
316+ message: `No operation found for tool "${toolId}"`,
317+ cause: undefined,
318+ });
319+ }
320+
321+ const { binding, config } = entry;
322+ const baseUrl = config.baseUrl;
323+
324+ // Resolve secret-backed headers
325+ const resolvedHeaders = yield* resolveHeaders(
326+ config.headers,
327+ opts.secrets,
328+ opts.scopeId,
329+ );
330+
331+ const clientWithBaseUrl = baseUrl
332+ ? Layer.effect(
333+ HttpClient.HttpClient,
334+ Effect.map(
335+ HttpClient.HttpClient,
336+ HttpClient.mapRequest(
337+ HttpClientRequest.prependUrl(baseUrl),
338+ ),
339+ ),
340+ ).pipe(Layer.provide(opts.httpClientLayer))
341+ : opts.httpClientLayer;
342+
343+ const result = yield* invoke(
344+ binding,
345+ (args ?? {}) as Record<string, unknown>,
346+ config,
347+ resolvedHeaders,
348+ ).pipe(Effect.provide(clientWithBaseUrl));
349+
350+ return new ToolInvocationResult({
351+ data: result.data,
352+ error: result.error,
353+ status: result.status,
354+ });
355+ }).pipe(
356+ Effect.catchAll((err) => {
357+ if (
358+ typeof err === "object" &&
359+ err !== null &&
360+ "_tag" in err &&
361+ (err as { _tag: string })._tag === "ToolInvocationError"
362+ ) {
363+ return Effect.fail(err as ToolInvocationError);
364+ }
365+ return Effect.fail(
366+ new ToolInvocationError({
367+ toolId,
368+ message: `OpenAPI invocation failed: ${err instanceof Error ? err.message : String(err)}`,
369+ cause: err,
370+ }),
371+ );
372+ }),
373+ ),
374+});
packages/plugins/openapi/src/sdk/kv-operation-store.ts+120−0
@@ -0,0 +1,120 @@
1+// ---------------------------------------------------------------------------
2+// KV-backed OpenApiOperationStore
3+//
4+// Uses two KV namespaces — one for bindings, one for sources (meta + config).
5+// ---------------------------------------------------------------------------
6+
7+import { Effect, Schema } from "effect";
8+import { scopeKv, makeInMemoryScopedKv, type Kv, type ToolId, type ScopedKv } from "@executor/sdk";
9+
10+import type { OpenApiOperationStore, StoredSource } from "./operation-store";
11+import { OperationBinding, InvocationConfig, HeaderValue } from "./types";
12+
13+// ---------------------------------------------------------------------------
14+// Stored schemas
15+// ---------------------------------------------------------------------------
16+
17+class StoredEntry extends Schema.Class<StoredEntry>("StoredEntry")({
18+ namespace: Schema.String,
19+ binding: OperationBinding,
20+ config: InvocationConfig,
21+}) {}
22+
23+const encodeEntry = Schema.encodeSync(Schema.parseJson(StoredEntry));
24+const decodeEntry = Schema.decodeUnknownSync(Schema.parseJson(StoredEntry));
25+
26+const StoredSourceSchema = Schema.Struct({
27+ namespace: Schema.String,
28+ name: Schema.String,
29+ config: Schema.Struct({
30+ spec: Schema.String,
31+ baseUrl: Schema.optional(Schema.String),
32+ namespace: Schema.optional(Schema.String),
33+ headers: Schema.optional(
34+ Schema.Record({ key: Schema.String, value: HeaderValue }),
35+ ),
36+ }),
37+});
38+const encodeSource = Schema.encodeSync(Schema.parseJson(StoredSourceSchema));
39+const decodeSource = Schema.decodeUnknownSync(Schema.parseJson(StoredSourceSchema));
40+
41+// ---------------------------------------------------------------------------
42+// Implementation
43+// ---------------------------------------------------------------------------
44+
45+const makeStore = (
46+ bindings: ScopedKv,
47+ sources: ScopedKv,
48+): OpenApiOperationStore => ({
49+ get: (toolId) =>
50+ Effect.gen(function* () {
51+ const raw = yield* bindings.get(toolId);
52+ if (!raw) return null;
53+ const entry = decodeEntry(raw);
54+ return { binding: entry.binding, config: entry.config };
55+ }),
56+
57+ put: (toolId, namespace, binding, config) =>
58+ bindings.set(
59+ toolId,
60+ encodeEntry(new StoredEntry({ namespace, binding, config })),
61+ ),
62+
63+ remove: (toolId) => bindings.delete(toolId).pipe(Effect.asVoid),
64+
65+ listByNamespace: (namespace) =>
66+ Effect.gen(function* () {
67+ const entries = yield* bindings.list();
68+ const ids: ToolId[] = [];
69+ for (const e of entries) {
70+ const entry = decodeEntry(e.value);
71+ if (entry.namespace === namespace) ids.push(e.key as ToolId);
72+ }
73+ return ids;
74+ }),
75+
76+ removeByNamespace: (namespace) =>
77+ Effect.gen(function* () {
78+ const entries = yield* bindings.list();
79+ const ids: ToolId[] = [];
80+ for (const e of entries) {
81+ const entry = decodeEntry(e.value);
82+ if (entry.namespace === namespace) {
83+ ids.push(e.key as ToolId);
84+ yield* bindings.delete(e.key);
85+ }
86+ }
87+ return ids;
88+ }),
89+
90+ putSource: (source) =>
91+ sources.set(source.namespace, encodeSource(source)),
92+
93+ removeSource: (namespace) =>
94+ sources.delete(namespace).pipe(Effect.asVoid),
95+
96+ listSources: () =>
97+ Effect.gen(function* () {
98+ const entries = yield* sources.list();
99+ return entries.map((e) => decodeSource(e.value) as StoredSource);
100+ }),
101+});
102+
103+// ---------------------------------------------------------------------------
104+// Factory from global Kv
105+// ---------------------------------------------------------------------------
106+
107+export const makeKvOperationStore = (
108+ kv: Kv,
109+ namespace: string,
110+): OpenApiOperationStore =>
111+ makeStore(
112+ scopeKv(kv, `${namespace}.bindings`),
113+ scopeKv(kv, `${namespace}.sources`),
114+ );
115+
116+export const makeInMemoryOperationStore = (): OpenApiOperationStore =>
117+ makeStore(
118+ makeInMemoryScopedKv(),
119+ makeInMemoryScopedKv(),
120+ );
packages/plugins/openapi/src/sdk/openapi-utils.ts+88−0
@@ -0,0 +1,88 @@
1+// ---------------------------------------------------------------------------
2+// OpenAPI type aliases and $ref resolution
3+//
4+// Wraps the openapi-types V3/V3_1 union mess and provides clean ref resolution.
5+// ---------------------------------------------------------------------------
6+
7+import { Option } from "effect";
8+import type { OpenAPIV3, OpenAPIV3_1 } from "openapi-types";
9+import type { ParsedDocument } from "./parse";
10+
11+// ---------------------------------------------------------------------------
12+// Type aliases — collapse V3 / V3_1 unions into single names
13+// ---------------------------------------------------------------------------
14+
15+export type ParameterObject = OpenAPIV3.ParameterObject | OpenAPIV3_1.ParameterObject;
16+export type OperationObject = OpenAPIV3.OperationObject | OpenAPIV3_1.OperationObject;
17+export type PathItemObject = OpenAPIV3.PathItemObject | OpenAPIV3_1.PathItemObject;
18+export type RequestBodyObject = OpenAPIV3.RequestBodyObject | OpenAPIV3_1.RequestBodyObject;
19+export type ResponseObject = OpenAPIV3.ResponseObject | OpenAPIV3_1.ResponseObject;
20+export type MediaTypeObject = OpenAPIV3.MediaTypeObject | OpenAPIV3_1.MediaTypeObject;
21+
22+// ---------------------------------------------------------------------------
23+// DocResolver — wraps a parsed document for clean $ref resolution
24+// ---------------------------------------------------------------------------
25+
26+export class DocResolver {
27+ constructor(readonly doc: ParsedDocument) {}
28+
29+ /** Resolve a value that might be a $ref, returning the resolved object */
30+ resolve<T>(value: T | OpenAPIV3.ReferenceObject | OpenAPIV3_1.ReferenceObject): T | null {
31+ if (isRef(value)) {
32+ const resolved = this.resolvePointer(value.$ref);
33+ return resolved as T | null;
34+ }
35+ return value as T;
36+ }
37+
38+ private resolvePointer(ref: string): unknown {
39+ if (!ref.startsWith("#/")) return null;
40+ const segments = ref.slice(2).split("/");
41+ let current: unknown = this.doc;
42+ for (const segment of segments) {
43+ if (typeof current !== "object" || current === null) return null;
44+ current = (current as Record<string, unknown>)[segment];
45+ }
46+ return current;
47+ }
48+}
49+
50+const isRef = (value: unknown): value is { $ref: string } =>
51+ typeof value === "object" && value !== null && "$ref" in value;
52+
53+// ---------------------------------------------------------------------------
54+// Server URL resolution
55+// ---------------------------------------------------------------------------
56+
57+export const resolveBaseUrl = (
58+ servers: readonly { url: string; variables: import("effect/Option").Option<Record<string, string>> }[],
59+): string => {
60+ const server = servers[0];
61+ if (!server) return "";
62+
63+ let url = server.url;
64+ if (Option.isSome(server.variables)) {
65+ for (const [name, value] of Object.entries(server.variables.value)) {
66+ url = url.replaceAll(`{${name}}`, value);
67+ }
68+ }
69+ return url;
70+};
71+
72+// ---------------------------------------------------------------------------
73+// Content negotiation
74+// ---------------------------------------------------------------------------
75+
76+/** Pick the preferred media type entry (prefer application/json) */
77+export const preferredContent = (
78+ content: Record<string, MediaTypeObject> | undefined,
79+): { mediaType: string; media: MediaTypeObject } | undefined => {
80+ if (!content) return undefined;
81+ const entries = Object.entries(content);
82+ const pick =
83+ entries.find(([mt]) => mt === "application/json") ??
84+ entries.find(([mt]) => mt.toLowerCase().includes("+json")) ??
85+ entries.find(([mt]) => mt.toLowerCase().includes("json")) ??
86+ entries[0];
87+ return pick ? { mediaType: pick[0], media: pick[1] } : undefined;
88+};
packages/plugins/openapi/src/sdk/operation-store.ts+46−0
@@ -0,0 +1,46 @@
1+import type { Effect } from "effect";
2+import type { ToolId } from "@executor/sdk";
3+
4+import type { OperationBinding, InvocationConfig, HeaderValue } from "./types";
5+
6+// ---------------------------------------------------------------------------
7+// Operation store — plugin's own storage for invocation data
8+// ---------------------------------------------------------------------------
9+
10+export interface SourceConfig {
11+ readonly spec: string;
12+ readonly baseUrl?: string;
13+ readonly namespace?: string;
14+ readonly headers?: Record<string, HeaderValue>;
15+}
16+
17+export interface StoredSource {
18+ readonly namespace: string;
19+ readonly name: string;
20+ readonly config: SourceConfig;
21+}
22+
23+export interface OpenApiOperationStore {
24+ readonly get: (
25+ toolId: ToolId,
26+ ) => Effect.Effect<{ binding: OperationBinding; config: InvocationConfig } | null>;
27+
28+ readonly put: (
29+ toolId: ToolId,
30+ namespace: string,
31+ binding: OperationBinding,
32+ config: InvocationConfig,
33+ ) => Effect.Effect<void>;
34+
35+ readonly remove: (toolId: ToolId) => Effect.Effect<void>;
36+
37+ readonly listByNamespace: (namespace: string) => Effect.Effect<readonly ToolId[]>;
38+
39+ readonly removeByNamespace: (namespace: string) => Effect.Effect<readonly ToolId[]>;
40+
41+ readonly putSource: (source: StoredSource) => Effect.Effect<void>;
42+
43+ readonly removeSource: (namespace: string) => Effect.Effect<void>;
44+
45+ readonly listSources: () => Effect.Effect<readonly StoredSource[]>;
46+}
packages/plugins/openapi/src/sdk/parse.ts+69−0
@@ -0,0 +1,69 @@
1+import SwaggerParser from "@apidevtools/swagger-parser";
2+import type { OpenAPI, OpenAPIV3, OpenAPIV3_1 } from "openapi-types";
3+import { Effect } from "effect";
4+
5+import { OpenApiParseError } from "./errors";
6+
7+export type ParsedDocument = OpenAPIV3.Document | OpenAPIV3_1.Document;
8+
9+/** Parse, validate, and bundle an OpenAPI document from text or URL */
10+export const parse = Effect.fn("OpenApi.parse")(function* (input: string) {
11+ const api: OpenAPI.Document = yield* Effect.tryPromise({
12+ try: () => {
13+ // If it looks like a URL, parse from URL; otherwise parse inline
14+ if (input.startsWith("http://") || input.startsWith("https://")) {
15+ return SwaggerParser.bundle(input);
16+ }
17+ // Parse from string: swagger-parser needs an object, so JSON/YAML parse first
18+ return SwaggerParser.bundle(parseTextToObject(input));
19+ },
20+ catch: (error) =>
21+ new OpenApiParseError({
22+ message: `Failed to parse OpenAPI document: ${error instanceof Error ? error.message : String(error)}`,
23+ error,
24+ }),
25+ });
26+
27+ // Ensure it's OpenAPI 3.x (not Swagger 2)
28+ if (!isOpenApi3(api)) {
29+ return yield* new OpenApiExtractionErrorFromParse({
30+ message:
31+ "Only OpenAPI 3.x documents are supported. Swagger 2.x documents should be converted first.",
32+ });
33+ }
34+
35+ return api as ParsedDocument;
36+});
37+
38+// ---------------------------------------------------------------------------
39+// Internals
40+// ---------------------------------------------------------------------------
41+
42+import YAML from "yaml";
43+import { OpenApiExtractionError } from "./errors";
44+
45+// swagger-parser's dereference needs a tagged error for this path
46+class OpenApiExtractionErrorFromParse extends OpenApiExtractionError {}
47+
48+const isOpenApi3 = (
49+ doc: OpenAPI.Document,
50+): doc is OpenAPIV3.Document | OpenAPIV3_1.Document =>
51+ "openapi" in doc && typeof doc.openapi === "string" && doc.openapi.startsWith("3.");
52+
53+const parseTextToObject = (text: string): OpenAPI.Document => {
54+ const trimmed = text.trim();
55+ if (trimmed.length === 0) throw new Error("OpenAPI document is empty");
56+
57+ let parsed: unknown;
58+ try {
59+ parsed = JSON.parse(trimmed);
60+ } catch {
61+ parsed = YAML.parse(trimmed);
62+ }
63+
64+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
65+ throw new Error("OpenAPI document must parse to an object");
66+ }
67+
68+ return parsed as OpenAPI.Document;
69+};
packages/plugins/openapi/src/sdk/plugin.test.ts+466−0
@@ -0,0 +1,466 @@
1+import { expect, layer } from "@effect/vitest";
2+import { Effect, Layer, Schema } from "effect";
3+import {
4+ HttpApi,
5+ HttpApiBuilder,
6+ HttpApiEndpoint,
7+ HttpApiGroup,
8+ HttpClient,
9+ HttpServerRequest,
10+ OpenApi,
11+} from "@effect/platform";
12+import { NodeHttpServer } from "@effect/platform-node";
13+
14+import {
15+ createExecutor,
16+ makeInMemoryPolicyEngine,
17+ makeInMemorySecretStore,
18+ makeInMemorySourceRegistry,
19+ makeInMemoryToolRegistry,
20+ makeTestConfig,
21+ ScopeId,
22+ SecretId,
23+ type InvokeOptions,
24+} from "@executor/sdk";
25+import { openApiPlugin } from "./plugin";
26+
27+const autoApprove: InvokeOptions = { onElicitation: "accept-all" };
28+
29+// ---------------------------------------------------------------------------
30+// Define a test API with Effect HttpApi
31+// ---------------------------------------------------------------------------
32+
33+class Item extends Schema.Class<Item>("Item")({
34+ id: Schema.Number,
35+ name: Schema.String,
36+}) {}
37+
38+class EchoHeaders extends Schema.Class<EchoHeaders>("EchoHeaders")({
39+ authorization: Schema.optional(Schema.String),
40+ "x-static": Schema.optional(Schema.String),
41+}) {}
42+
43+const ItemsGroup = HttpApiGroup.make("items")
44+ .add(
45+ HttpApiEndpoint.get("listItems", "/items").addSuccess(Schema.Array(Item)),
46+ )
47+ .add(
48+ HttpApiEndpoint.get("getItem", "/items/:itemId")
49+ .setPath(Schema.Struct({ itemId: Schema.NumberFromString }))
50+ .addSuccess(Item),
51+ )
52+ .add(
53+ HttpApiEndpoint.get("echoHeaders", "/echo-headers").addSuccess(EchoHeaders),
54+ );
55+
56+const TestApi = HttpApi.make("testApi").add(ItemsGroup);
57+
58+const spec = OpenApi.fromApi(TestApi);
59+const specJson = JSON.stringify(spec);
60+
61+// ---------------------------------------------------------------------------
62+// Implement handlers
63+// ---------------------------------------------------------------------------
64+
65+const ITEMS = [
66+ { id: 1, name: "Widget" },
67+ { id: 2, name: "Gadget" },
68+ { id: 3, name: "Doohickey" },
69+];
70+
71+const ItemsGroupLive = HttpApiBuilder.group(TestApi, "items", (handlers) =>
72+ handlers
73+ .handle("listItems", () => Effect.succeed(ITEMS))
74+ .handle("getItem", (req) =>
75+ Effect.succeed(
76+ ITEMS.find((i) => i.id === req.path.itemId) ?? {
77+ id: 0,
78+ name: "Unknown",
79+ },
80+ ),
81+ )
82+ .handle("echoHeaders", () =>
83+ Effect.gen(function* () {
84+ const req = yield* HttpServerRequest.HttpServerRequest;
85+ return new EchoHeaders({
86+ authorization: req.headers["authorization"],
87+ "x-static": req.headers["x-static"],
88+ });
89+ }),
90+ ),
91+);
92+
93+// ---------------------------------------------------------------------------
94+// Test layer: real server on port 0 + HttpClient pointing at it
95+// ---------------------------------------------------------------------------
96+
97+const ApiLive = HttpApiBuilder.api(TestApi).pipe(
98+ Layer.provide(ItemsGroupLive),
99+);
100+
101+const TestLayer = HttpApiBuilder.serve().pipe(
102+ Layer.provide(ApiLive),
103+ Layer.provideMerge(NodeHttpServer.layerTest),
104+);
105+
106+// ---------------------------------------------------------------------------
107+// Tests — layer() shares the server across all tests in this describe block
108+// ---------------------------------------------------------------------------
109+
110+layer(TestLayer)("OpenAPI Plugin", (it) => {
111+ it.effect("previewSpec returns metadata and header presets", () =>
112+ Effect.gen(function* () {
113+ const httpClient = yield* HttpClient.HttpClient;
114+ const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient);
115+
116+ const executor = yield* createExecutor(
117+ makeTestConfig({
118+ plugins: [
119+ openApiPlugin({ httpClientLayer: clientLayer }),
120+ ] as const,
121+ }),
122+ );
123+
124+ const preview = yield* executor.openapi.previewSpec(specJson);
125+
126+ expect(preview.operationCount).toBeGreaterThanOrEqual(2);
127+ expect(preview.servers).toBeDefined();
128+ }),
129+ );
130+
131+ it.effect("registers runtime openapi tools under built-in source", () =>
132+ Effect.gen(function* () {
133+ const httpClient = yield* HttpClient.HttpClient;
134+ const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient);
135+
136+ const executor = yield* createExecutor(
137+ makeTestConfig({
138+ plugins: [
139+ openApiPlugin({ httpClientLayer: clientLayer }),
140+ ] as const,
141+ }),
142+ );
143+
144+ const tools = yield* executor.tools.list();
145+ const ids = tools.map((t) => t.id);
146+ expect(ids).toContain("openapi.previewSpec");
147+ expect(ids).toContain("openapi.addSource");
148+ }),
149+ );
150+
151+ it.effect("lists built-in as a runtime source", () =>
152+ Effect.gen(function* () {
153+ const httpClient = yield* HttpClient.HttpClient;
154+ const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient);
155+
156+ const executor = yield* createExecutor(
157+ makeTestConfig({
158+ plugins: [
159+ openApiPlugin({ httpClientLayer: clientLayer }),
160+ ] as const,
161+ }),
162+ );
163+
164+ const sources = yield* executor.sources.list();
165+
166+ expect(sources).toContainEqual(expect.objectContaining({
167+ id: "built-in",
168+ name: "Built In",
169+ kind: "built-in",
170+ runtime: true,
171+ canRemove: false,
172+ canRefresh: false,
173+ }));
174+ }),
175+ );
176+
177+ it.effect("closing an executor does not remove added source tools from the shared registry", () =>
178+ Effect.gen(function* () {
179+ const httpClient = yield* HttpClient.HttpClient;
180+ const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient);
181+
182+ const scope = {
183+ id: ScopeId.make("test-scope"),
184+ name: "/test",
185+ createdAt: new Date(),
186+ } as const;
187+
188+ const sharedConfig = {
189+ scope,
190+ tools: makeInMemoryToolRegistry(),
191+ sources: makeInMemorySourceRegistry(),
192+ secrets: makeInMemorySecretStore(),
193+ policies: makeInMemoryPolicyEngine(),
194+ };
195+
196+ const executor1 = yield* createExecutor({
197+ ...sharedConfig,
198+ plugins: [
199+ openApiPlugin({ httpClientLayer: clientLayer }),
200+ ] as const,
201+ });
202+
203+ yield* executor1.openapi.addSpec({
204+ spec: specJson,
205+ namespace: "persisted",
206+ });
207+
208+ expect((yield* executor1.tools.list()).map((tool) => tool.id)).toContain(
209+ "persisted.items.listItems",
210+ );
211+
212+ yield* executor1.close();
213+
214+ const executor2 = yield* createExecutor({
215+ ...sharedConfig,
216+ plugins: [
217+ openApiPlugin({ httpClientLayer: clientLayer }),
218+ ] as const,
219+ });
220+
221+ expect((yield* executor2.tools.list()).map((tool) => tool.id)).toContain(
222+ "persisted.items.listItems",
223+ );
224+ }),
225+ );
226+
227+ it.effect("invokes runtime previewSpec through executor.tools.invoke", () =>
228+ Effect.gen(function* () {
229+ const httpClient = yield* HttpClient.HttpClient;
230+ const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient);
231+
232+ const executor = yield* createExecutor(
233+ makeTestConfig({
234+ plugins: [
235+ openApiPlugin({ httpClientLayer: clientLayer }),
236+ ] as const,
237+ }),
238+ );
239+
240+ const result = yield* executor.tools.invoke(
241+ "openapi.previewSpec",
242+ { spec: specJson },
243+ autoApprove,
244+ );
245+
246+ expect(result.error).toBeNull();
247+ expect((result.data as { operationCount: number }).operationCount).toBeGreaterThanOrEqual(2);
248+ }),
249+ );
250+
251+ it.effect("invokes runtime addSource through executor.tools.invoke", () =>
252+ Effect.gen(function* () {
253+ const httpClient = yield* HttpClient.HttpClient;
254+ const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient);
255+
256+ const executor = yield* createExecutor(
257+ makeTestConfig({
258+ plugins: [
259+ openApiPlugin({ httpClientLayer: clientLayer }),
260+ ] as const,
261+ }),
262+ );
263+
264+ const result = yield* executor.tools.invoke(
265+ "openapi.addSource",
266+ { spec: specJson, namespace: "runtime" },
267+ autoApprove,
268+ );
269+
270+ expect(result.error).toBeNull();
271+ expect(result.data).toEqual({ sourceId: "runtime", toolCount: 3 });
272+ expect((yield* executor.tools.list()).map((t) => t.id)).toContain(
273+ "runtime.items.listItems",
274+ );
275+ }),
276+ );
277+
278+ it.effect("resolves secret-backed headers at invocation time", () =>
279+ Effect.gen(function* () {
280+ const httpClient = yield* HttpClient.HttpClient;
281+ const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient);
282+
283+ const executor = yield* createExecutor(
284+ makeTestConfig({
285+ plugins: [
286+ openApiPlugin({ httpClientLayer: clientLayer }),
287+ ] as const,
288+ }),
289+ );
290+
291+ // Store a secret
292+ yield* executor.secrets.set({
293+ id: SecretId.make("test-api-token"),
294+ name: "Test API Token",
295+ value: "secret-value-123",
296+ });
297+
298+ // Add spec with secret-backed header
299+ yield* executor.openapi.addSpec({
300+ spec: specJson,
301+ namespace: "authed",
302+ baseUrl: "",
303+ headers: {
304+ "Authorization": { secretId: "test-api-token", prefix: "Bearer " },
305+ "X-Static": "hello",
306+ },
307+ });
308+
309+ // Invoke the echo endpoint — verifies secret was resolved and sent
310+ const result = yield* executor.tools.invoke(
311+ "authed.items.echoHeaders",
312+ {},
313+ autoApprove,
314+ );
315+
316+ expect(result.error).toBeNull();
317+ const data = result.data as { authorization?: string; "x-static"?: string };
318+ expect(data.authorization).toBe("Bearer secret-value-123");
319+ expect(data["x-static"]).toBe("hello");
320+ }),
321+ );
322+
323+ it.effect("fails clearly when a secret is missing", () =>
324+ Effect.gen(function* () {
325+ const httpClient = yield* HttpClient.HttpClient;
326+ const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient);
327+
328+ const executor = yield* createExecutor(
329+ makeTestConfig({
330+ plugins: [
331+ openApiPlugin({ httpClientLayer: clientLayer }),
332+ ] as const,
333+ }),
334+ );
335+
336+ // Add spec with secret-backed header but DON'T store the secret
337+ yield* executor.openapi.addSpec({
338+ spec: specJson,
339+ namespace: "noauth",
340+ baseUrl: "",
341+ headers: {
342+ "Authorization": { secretId: "missing-token", prefix: "Bearer " },
343+ },
344+ });
345+
346+ // Invoke — should fail with a clear error about the missing secret
347+ const error = yield* Effect.flip(
348+ executor.tools.invoke("noauth.items.listItems", {}, autoApprove),
349+ );
350+
351+ expect(error._tag).toBe("ToolInvocationError");
352+ expect((error as { message: string }).message).toContain("missing-token");
353+ }),
354+ );
355+
356+ it.effect("registers tools from an OpenAPI spec", () =>
357+ Effect.gen(function* () {
358+ const httpClient = yield* HttpClient.HttpClient;
359+ const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient);
360+
361+ const executor = yield* createExecutor(
362+ makeTestConfig({
363+ plugins: [
364+ openApiPlugin({ httpClientLayer: clientLayer }),
365+ ] as const,
366+ }),
367+ );
368+
369+ const result = yield* executor.openapi.addSpec({
370+ spec: specJson,
371+ namespace: "test",
372+ baseUrl: "",
373+ });
374+
375+ expect(result.toolCount).toBeGreaterThanOrEqual(2);
376+
377+ const tools = yield* executor.tools.list();
378+ const names = tools.map((t) => t.name);
379+ expect(names).toContain("items.listItems");
380+ expect(names).toContain("items.getItem");
381+ }),
382+ );
383+
384+ it.effect("invokes listItems", () =>
385+ Effect.gen(function* () {
386+ const httpClient = yield* HttpClient.HttpClient;
387+ const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient);
388+
389+ const executor = yield* createExecutor(
390+ makeTestConfig({
391+ plugins: [
392+ openApiPlugin({ httpClientLayer: clientLayer }),
393+ ] as const,
394+ }),
395+ );
396+
397+ yield* executor.openapi.addSpec({
398+ spec: specJson,
399+ namespace: "test",
400+ baseUrl: "",
401+ });
402+
403+ const result = yield* executor.tools.invoke("test.items.listItems", {}, autoApprove);
404+ expect(result.error).toBeNull();
405+ expect(result.data).toEqual(ITEMS);
406+ }),
407+ );
408+
409+ it.effect("invokes getItem with path parameter", () =>
410+ Effect.gen(function* () {
411+ const httpClient = yield* HttpClient.HttpClient;
412+ const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient);
413+
414+ const executor = yield* createExecutor(
415+ makeTestConfig({
416+ plugins: [
417+ openApiPlugin({ httpClientLayer: clientLayer }),
418+ ] as const,
419+ }),
420+ );
421+
422+ yield* executor.openapi.addSpec({
423+ spec: specJson,
424+ namespace: "test",
425+ baseUrl: "",
426+ });
427+
428+ const result = yield* executor.tools.invoke("test.items.getItem", {
429+ itemId: "2",
430+ }, autoApprove);
431+ expect(result.error).toBeNull();
432+ expect(result.data).toEqual({ id: 2, name: "Gadget" });
433+ }),
434+ );
435+
436+ it.effect("removeSpec cleans up registered tools", () =>
437+ Effect.gen(function* () {
438+ const httpClient = yield* HttpClient.HttpClient;
439+ const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient);
440+
441+ const executor = yield* createExecutor(
442+ makeTestConfig({
443+ plugins: [
444+ openApiPlugin({ httpClientLayer: clientLayer }),
445+ ] as const,
446+ }),
447+ );
448+
449+ yield* executor.openapi.addSpec({
450+ spec: specJson,
451+ namespace: "removable",
452+ baseUrl: "",
453+ });
454+
455+ expect((yield* executor.tools.list()).length).toBeGreaterThan(0);
456+
457+ yield* executor.openapi.removeSpec("removable");
458+
459+ const remaining = yield* executor.tools.list();
460+ expect(remaining.map((tool) => tool.id)).toEqual([
461+ "openapi.previewSpec",
462+ "openapi.addSource",
463+ ]);
464+ }),
465+ );
466+});
packages/plugins/openapi/src/sdk/plugin.ts+361−0
@@ -0,0 +1,361 @@
1+import { Effect, Option, Schema } from "effect";
2+import { FetchHttpClient, HttpClient } from "@effect/platform";
3+import type { Layer } from "effect";
4+
5+import {
6+ Source,
7+ SourceDetectionResult,
8+ definePlugin,
9+ registerRuntimeTools,
10+ runtimeTool,
11+ type ExecutorPlugin,
12+ type PluginContext,
13+ ToolId,
14+ type ToolRegistration,
15+} from "@executor/sdk";
16+
17+import { parse } from "./parse";
18+import { extract } from "./extract";
19+import { compileToolDefinitions, type ToolDefinition } from "./definitions";
20+import { makeOpenApiInvoker } from "./invoke";
21+import { resolveBaseUrl } from "./openapi-utils";
22+import type { OpenApiOperationStore } from "./operation-store";
23+import { makeInMemoryOperationStore } from "./kv-operation-store";
24+import { previewSpec, SpecPreview } from "./preview";
25+import {
26+ HeaderValue as HeaderValueSchema,
27+ InvocationConfig,
28+ OperationBinding,
29+ type HeaderValue as HeaderValueValue,
30+} from "./types";
31+
32+// ---------------------------------------------------------------------------
33+// Plugin config
34+// ---------------------------------------------------------------------------
35+
36+/** A header value — either a static string or a reference to a secret */
37+export type HeaderValue = HeaderValueValue;
38+
39+export interface OpenApiSpecConfig {
40+ readonly spec: string;
41+ readonly baseUrl?: string;
42+ readonly namespace?: string;
43+ /** Headers applied to every request. Values can reference secrets. */
44+ readonly headers?: Record<string, HeaderValue>;
45+}
46+
47+// ---------------------------------------------------------------------------
48+// Plugin extension
49+// ---------------------------------------------------------------------------
50+
51+export interface OpenApiPluginExtension {
52+ /** Preview a spec without registering — returns metadata, auth strategies, header presets */
53+ readonly previewSpec: (specText: string) => Effect.Effect<SpecPreview, Error>;
54+
55+ /** Add an OpenAPI spec and register its operations as tools */
56+ readonly addSpec: (
57+ config: OpenApiSpecConfig,
58+ ) => Effect.Effect<{ readonly toolCount: number }, Error>;
59+
60+ /** Remove all tools from a previously added spec by namespace */
61+ readonly removeSpec: (namespace: string) => Effect.Effect<void>;
62+}
63+
64+// ---------------------------------------------------------------------------
65+// Helpers
66+// ---------------------------------------------------------------------------
67+
68+const PreviewSpecInputSchema = Schema.Struct({
69+ spec: Schema.String,
70+});
71+type PreviewSpecInput = typeof PreviewSpecInputSchema.Type;
72+
73+const AddSourceInputSchema = Schema.Struct({
74+ spec: Schema.String,
75+ baseUrl: Schema.optional(Schema.String),
76+ namespace: Schema.optional(Schema.String),
77+ headers: Schema.optional(
78+ Schema.Record({ key: Schema.String, value: HeaderValueSchema }),
79+ ),
80+});
81+type AddSourceInput = typeof AddSourceInputSchema.Type;
82+
83+const AddSourceOutputSchema = Schema.Struct({
84+ sourceId: Schema.String,
85+ toolCount: Schema.Number,
86+});
87+
88+/** Rewrite OpenAPI `#/components/schemas/X` refs to standard `#/$defs/X`. */
89+const normalizeOpenApiRefs = (node: unknown): unknown => {
90+ if (node == null || typeof node !== "object") return node;
91+ if (Array.isArray(node)) {
92+ let changed = false;
93+ const out = node.map((item) => {
94+ const n = normalizeOpenApiRefs(item);
95+ if (n !== item) changed = true;
96+ return n;
97+ });
98+ return changed ? out : node;
99+ }
100+
101+ const obj = node as Record<string, unknown>;
102+
103+ if (typeof obj.$ref === "string") {
104+ const match = obj.$ref.match(/^#\/components\/schemas\/(.+)$/);
105+ if (match) return { ...obj, $ref: `#/$defs/${match[1]}` };
106+ return obj;
107+ }
108+
109+ let changed = false;
110+ const result: Record<string, unknown> = {};
111+ for (const [k, v] of Object.entries(obj)) {
112+ const n = normalizeOpenApiRefs(v);
113+ if (n !== v) changed = true;
114+ result[k] = n;
115+ }
116+ return changed ? result : obj;
117+};
118+
119+const toRegistration = (
120+ def: ToolDefinition,
121+ namespace: string,
122+): ToolRegistration => {
123+ const op = def.operation;
124+ const description = Option.getOrElse(op.description, () =>
125+ Option.getOrElse(
126+ op.summary,
127+ () => `${op.method.toUpperCase()} ${op.pathTemplate}`,
128+ ),
129+ );
130+ return {
131+ id: ToolId.make(`${namespace}.${def.toolPath}`),
132+ pluginKey: "openapi",
133+ sourceId: namespace,
134+ name: def.toolPath,
135+ description,
136+ inputSchema: normalizeOpenApiRefs(Option.getOrUndefined(op.inputSchema)),
137+ outputSchema: normalizeOpenApiRefs(Option.getOrUndefined(op.outputSchema)),
138+ };
139+};
140+
141+const toBinding = (def: ToolDefinition): OperationBinding =>
142+ new OperationBinding({
143+ method: def.operation.method,
144+ pathTemplate: def.operation.pathTemplate,
145+ parameters: [...def.operation.parameters],
146+ requestBody: def.operation.requestBody,
147+ });
148+
149+// ---------------------------------------------------------------------------
150+// Plugin factory
151+// ---------------------------------------------------------------------------
152+
153+export const openApiPlugin = (options?: {
154+ readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>;
155+ readonly operationStore?: OpenApiOperationStore;
156+}): ExecutorPlugin<"openapi", OpenApiPluginExtension> => {
157+ const httpClientLayer = options?.httpClientLayer ?? FetchHttpClient.layer;
158+ const operationStore =
159+ options?.operationStore ?? makeInMemoryOperationStore();
160+
161+ return definePlugin({
162+ key: "openapi",
163+ init: (ctx: PluginContext) =>
164+ Effect.gen(function* () {
165+ yield* ctx.tools.registerInvoker(
166+ "openapi",
167+ makeOpenApiInvoker({
168+ operationStore,
169+ httpClientLayer,
170+ secrets: ctx.secrets,
171+ scopeId: ctx.scope.id,
172+ }),
173+ );
174+
175+ // Tools are already persisted in the KV tool registry — no need to
176+ // re-register them. We only need the source list and the invoker.
177+ // Register source manager so the core can list/remove/refresh our sources
178+ yield* ctx.sources.addManager({
179+ kind: "openapi",
180+
181+ list: () =>
182+ operationStore.listSources().pipe(
183+ Effect.map((metas) =>
184+ metas.map(
185+ (s) =>
186+ new Source({
187+ id: s.namespace,
188+ name: s.name,
189+ kind: "openapi",
190+ runtime: false,
191+ canRemove: true,
192+ canRefresh: false,
193+ }),
194+ ),
195+ ),
196+ ),
197+
198+ remove: (sourceId: string) =>
199+ Effect.gen(function* () {
200+ yield* operationStore.removeByNamespace(sourceId);
201+ yield* operationStore.removeSource(sourceId);
202+ yield* ctx.tools.unregisterBySource(sourceId);
203+ }),
204+
205+ detect: (url: string) =>
206+ Effect.gen(function* () {
207+ const trimmed = url.trim();
208+ if (!trimmed) return null;
209+ try {
210+ new URL(trimmed);
211+ } catch {
212+ return null;
213+ }
214+
215+ // Try fetching the URL and parsing as OpenAPI spec
216+ // parse() handles both URLs directly and spec text
217+ const doc = yield* parse(trimmed).pipe(
218+ Effect.catchAll(() => Effect.succeed(null)),
219+ );
220+ if (!doc) return null;
221+
222+ const result = yield* extract(doc).pipe(
223+ Effect.catchAll(() => Effect.succeed(null)),
224+ );
225+ if (!result) return null;
226+
227+ const namespace = Option.getOrElse(result.title, () => "api")
228+ .toLowerCase()
229+ .replace(/[^a-z0-9]+/g, "_");
230+ const name = Option.getOrElse(result.title, () => namespace);
231+
232+ return new SourceDetectionResult({
233+ kind: "openapi",
234+ confidence: "high",
235+ endpoint: trimmed,
236+ name,
237+ namespace,
238+ });
239+ }),
240+ });
241+
242+ const addSpecInternal = (config: OpenApiSpecConfig) =>
243+ Effect.gen(function* () {
244+ const doc = yield* parse(config.spec);
245+ const result = yield* extract(doc);
246+
247+ const namespace =
248+ config.namespace ??
249+ Option.getOrElse(result.title, () => "api")
250+ .toLowerCase()
251+ .replace(/[^a-z0-9]+/g, "_");
252+
253+ if (doc.components?.schemas) {
254+ // Normalize OpenAPI $ref format to standard JSON Schema $defs
255+ const defs: Record<string, unknown> = {};
256+ for (const [k, v] of Object.entries(doc.components.schemas)) {
257+ defs[k] = normalizeOpenApiRefs(v);
258+ }
259+ yield* ctx.tools.registerDefinitions(defs);
260+ }
261+
262+ const baseUrl = config.baseUrl ?? resolveBaseUrl(result.servers);
263+ const invocationConfig = new InvocationConfig({
264+ baseUrl,
265+ headers: config.headers ?? {},
266+ });
267+
268+ const definitions = compileToolDefinitions(result.operations);
269+
270+ const registrations = definitions.map((def) =>
271+ toRegistration(def, namespace),
272+ );
273+
274+ yield* Effect.forEach(
275+ definitions,
276+ (def) =>
277+ operationStore.put(
278+ ToolId.make(`${namespace}.${def.toolPath}`),
279+ namespace,
280+ toBinding(def),
281+ invocationConfig,
282+ ),
283+ { discard: true },
284+ );
285+
286+ yield* ctx.tools.register(registrations);
287+
288+ const sourceName = Option.getOrElse(result.title, () => namespace);
289+ yield* operationStore.putSource({
290+ namespace,
291+ name: sourceName,
292+ config: {
293+ spec: config.spec,
294+ baseUrl: config.baseUrl,
295+ namespace: config.namespace,
296+ headers: config.headers,
297+ },
298+ });
299+
300+ return { sourceId: namespace, toolCount: registrations.length };
301+ });
302+
303+ const runtimeTools = yield* registerRuntimeTools({
304+ registry: ctx.tools,
305+ sources: ctx.sources,
306+ pluginKey: "openapi",
307+ source: {
308+ id: "built-in",
309+ name: "Built In",
310+ kind: "built-in",
311+ },
312+ tools: [
313+ runtimeTool({
314+ id: "openapi.previewSpec",
315+ name: "openapi.previewSpec",
316+ description:
317+ "Preview an OpenAPI document before adding it as a source",
318+ inputSchema: PreviewSpecInputSchema,
319+ outputSchema: SpecPreview,
320+ handler: ({ spec }: PreviewSpecInput) => previewSpec(spec),
321+ }),
322+ runtimeTool({
323+ id: "openapi.addSource",
324+ name: "openapi.addSource",
325+ description:
326+ "Add an OpenAPI source and register its operations as tools",
327+ inputSchema: AddSourceInputSchema,
328+ outputSchema: AddSourceOutputSchema,
329+ handler: (input: AddSourceInput) => addSpecInternal(input),
330+ }),
331+ ],
332+ });
333+
334+ return {
335+ extension: {
336+ previewSpec: (specText: string) => previewSpec(specText),
337+
338+ addSpec: (config: OpenApiSpecConfig) =>
339+ addSpecInternal(config).pipe(
340+ Effect.map(({ toolCount }) => ({ toolCount })),
341+ ),
342+
343+ removeSpec: (namespace: string) =>
344+ Effect.gen(function* () {
345+ const toolIds =
346+ yield* operationStore.removeByNamespace(namespace);
347+ if (toolIds.length > 0) {
348+ yield* ctx.tools.unregister(toolIds);
349+ }
350+ yield* operationStore.removeSource(namespace);
351+ }),
352+ },
353+
354+ close: () =>
355+ Effect.gen(function* () {
356+ yield* runtimeTools.close();
357+ }),
358+ };
359+ }),
360+ });
361+};
packages/plugins/openapi/src/sdk/presets.ts+66−0
@@ -0,0 +1,66 @@
1+export interface OpenApiPreset {
2+ readonly id: string;
3+ readonly name: string;
4+ readonly summary: string;
5+ readonly url: string;
6+ readonly icon?: string;
7+}
8+
9+export const openApiPresets: readonly OpenApiPreset[] = [
10+ {
11+ id: "stripe",
12+ name: "Stripe",
13+ summary: "Payments, subscriptions, customers, and invoices.",
14+ url: "https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json",
15+ icon: "https://stripe.com/favicon.ico",
16+ },
17+ {
18+ id: "github-rest",
19+ name: "GitHub REST",
20+ summary: "Repos, issues, pull requests, actions, and users.",
21+ url: "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.json",
22+ icon: "https://github.com/favicon.ico",
23+ },
24+ {
25+ id: "asana",
26+ name: "Asana",
27+ summary: "Tasks, projects, teams, and workspace management.",
28+ url: "https://raw.githubusercontent.com/APIs-guru/openapi-directory/main/APIs/asana.com/1.0/openapi.yaml",
29+ icon: "https://asana.com/favicon.ico",
30+ },
31+ {
32+ id: "cloudflare",
33+ name: "Cloudflare",
34+ summary: "DNS, workers, pages, R2, and security rules.",
35+ url: "https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.json",
36+ icon: "https://cloudflare.com/favicon.ico",
37+ },
38+ {
39+ id: "vercel",
40+ name: "Vercel",
41+ summary: "Deployments, domains, projects, and edge config.",
42+ url: "https://openapi.vercel.sh",
43+ icon: "https://vercel.com/favicon.ico",
44+ },
45+ {
46+ id: "twilio",
47+ name: "Twilio",
48+ summary: "SMS, voice, video, and messaging APIs.",
49+ url: "https://raw.githubusercontent.com/twilio/twilio-oai/main/spec/json/twilio_api_v2010.json",
50+ icon: "https://twilio.com/favicon.ico",
51+ },
52+ {
53+ id: "neon",
54+ name: "Neon",
55+ summary: "Serverless Postgres — projects, branches, and endpoints.",
56+ url: "https://neon.tech/api_spec/release/v2.json",
57+ icon: "https://neon.tech/favicon/favicon.ico",
58+ },
59+ {
60+ id: "digitalocean",
61+ name: "DigitalOcean",
62+ summary: "Droplets, Kubernetes, databases, and networking.",
63+ url: "https://raw.githubusercontent.com/digitalocean/openapi/main/specification/DigitalOcean-public.v2.yaml",
64+ icon: "https://www.digitalocean.com/_next/static/media/favicon.594d6067.ico",
65+ },
66+];
packages/plugins/openapi/src/sdk/preview.ts+192−0
@@ -0,0 +1,192 @@
1+import { Effect, Option } from "effect";
2+import { Schema } from "effect";
3+
4+import { parse } from "./parse";
5+import { extract } from "./extract";
6+import type { ExtractionResult } from "./types";
7+
8+// ---------------------------------------------------------------------------
9+// Security scheme — what the spec declares it needs
10+// ---------------------------------------------------------------------------
11+
12+export class SecurityScheme extends Schema.Class<SecurityScheme>("SecurityScheme")({
13+ /** Key name in components.securitySchemes (e.g. "api_token") */
14+ name: Schema.String,
15+ /** OpenAPI security scheme type */
16+ type: Schema.Literal("http", "apiKey", "oauth2", "openIdConnect"),
17+ /** For type: "http" — e.g. "bearer", "basic" */
18+ scheme: Schema.optionalWith(Schema.String, { as: "Option" }),
19+ /** For type: "apiKey" — where the key goes */
20+ in: Schema.optionalWith(Schema.Literal("header", "query", "cookie"), { as: "Option" }),
21+ /** For type: "apiKey" — the header/query/cookie name */
22+ headerName: Schema.optionalWith(Schema.String, { as: "Option" }),
23+ description: Schema.optionalWith(Schema.String, { as: "Option" }),
24+}) {}
25+
26+// ---------------------------------------------------------------------------
27+// Auth strategy — a valid combination of security schemes
28+// ---------------------------------------------------------------------------
29+
30+export class AuthStrategy extends Schema.Class<AuthStrategy>("AuthStrategy")({
31+ /** The security schemes required together for this strategy */
32+ schemes: Schema.Array(Schema.String),
33+}) {}
34+
35+// ---------------------------------------------------------------------------
36+// Header preset — derived from an auth strategy
37+// ---------------------------------------------------------------------------
38+
39+export class HeaderPreset extends Schema.Class<HeaderPreset>("HeaderPreset")({
40+ /** Human-readable label for the UI (e.g. "Bearer Token", "API Key + Email") */
41+ label: Schema.String,
42+ /** Headers this strategy needs. Value is null when the user must provide it. */
43+ headers: Schema.Record({ key: Schema.String, value: Schema.NullOr(Schema.String) }),
44+ /** Which headers should be stored as secrets */
45+ secretHeaders: Schema.Array(Schema.String),
46+}) {}
47+
48+// ---------------------------------------------------------------------------
49+// Spec preview — everything the frontend needs
50+// ---------------------------------------------------------------------------
51+
52+export class SpecPreview extends Schema.Class<SpecPreview>("SpecPreview")({
53+ title: Schema.optionalWith(Schema.String, { as: "Option" }),
54+ version: Schema.optionalWith(Schema.String, { as: "Option" }),
55+ /** Reuses ServerInfo from extraction */
56+ servers: Schema.Array(Schema.Unknown),
57+ operationCount: Schema.Number,
58+ tags: Schema.Array(Schema.String),
59+ securitySchemes: Schema.Array(SecurityScheme),
60+ /** Valid auth strategies (each is a set of schemes used together) */
61+ authStrategies: Schema.Array(AuthStrategy),
62+ /** Pre-built header presets derived from auth strategies */
63+ headerPresets: Schema.Array(HeaderPreset),
64+}) {}
65+
66+// ---------------------------------------------------------------------------
67+// Security scheme extraction
68+// ---------------------------------------------------------------------------
69+
70+const extractSecuritySchemes = (
71+ rawSchemes: Record<string, unknown>,
72+): SecurityScheme[] =>
73+ Object.entries(rawSchemes).flatMap(([name, schemeOrRef]) => {
74+ if (!schemeOrRef || typeof schemeOrRef !== "object") return [];
75+ const scheme = schemeOrRef as Record<string, unknown>;
76+ if ("$ref" in scheme) return [];
77+
78+ const type = scheme.type as string;
79+ if (!["http", "apiKey", "oauth2", "openIdConnect"].includes(type)) return [];
80+
81+ return [
82+ new SecurityScheme({
83+ name,
84+ type: type as "http" | "apiKey" | "oauth2" | "openIdConnect",
85+ scheme: Option.fromNullable(scheme.scheme as string | undefined),
86+ in: Option.fromNullable(
87+ scheme.in as "header" | "query" | "cookie" | undefined,
88+ ),
89+ headerName: Option.fromNullable(scheme.name as string | undefined),
90+ description: Option.fromNullable(scheme.description as string | undefined),
91+ }),
92+ ];
93+ });
94+
95+// ---------------------------------------------------------------------------
96+// Header preset builder
97+// ---------------------------------------------------------------------------
98+
99+const buildHeaderPresets = (
100+ schemes: readonly SecurityScheme[],
101+ strategies: readonly AuthStrategy[],
102+): HeaderPreset[] => {
103+ const schemeMap = new Map(schemes.map((s) => [s.name, s]));
104+
105+ return strategies.flatMap((strategy) => {
106+ const resolved = strategy.schemes
107+ .map((name) => schemeMap.get(name))
108+ .filter((s): s is SecurityScheme => s !== undefined);
109+
110+ if (resolved.length === 0) return [];
111+
112+ const headers: Record<string, string | null> = {};
113+ const secretHeaders: string[] = [];
114+ const labelParts: string[] = [];
115+
116+ for (const scheme of resolved) {
117+ if (scheme.type === "http" && Option.getOrElse(scheme.scheme, () => "") === "bearer") {
118+ headers["Authorization"] = null;
119+ secretHeaders.push("Authorization");
120+ labelParts.push("Bearer Token");
121+ } else if (scheme.type === "http" && Option.getOrElse(scheme.scheme, () => "") === "basic") {
122+ headers["Authorization"] = null;
123+ secretHeaders.push("Authorization");
124+ labelParts.push("Basic Auth");
125+ } else if (scheme.type === "apiKey" && Option.getOrElse(scheme.in, () => "") === "header") {
126+ const headerName = Option.getOrElse(scheme.headerName, () => scheme.name);
127+ headers[headerName] = null;
128+ secretHeaders.push(headerName);
129+ labelParts.push(scheme.name);
130+ } else if (scheme.type === "apiKey") {
131+ labelParts.push(`${scheme.name} (${Option.getOrElse(scheme.in, () => "unknown")})`);
132+ } else {
133+ labelParts.push(scheme.name);
134+ }
135+ }
136+
137+ if (Object.keys(headers).length === 0 && resolved.length > 0) {
138+ return [
139+ new HeaderPreset({ label: labelParts.join(" + "), headers: {}, secretHeaders: [] }),
140+ ];
141+ }
142+
143+ return [
144+ new HeaderPreset({ label: labelParts.join(" + "), headers, secretHeaders }),
145+ ];
146+ });
147+};
148+
149+// ---------------------------------------------------------------------------
150+// Collect unique tags from extraction result
151+// ---------------------------------------------------------------------------
152+
153+const collectTags = (result: ExtractionResult): string[] => {
154+ const tagSet = new Set<string>();
155+ for (const op of result.operations) {
156+ for (const tag of op.tags) tagSet.add(tag);
157+ }
158+ return [...tagSet].sort();
159+};
160+
161+// ---------------------------------------------------------------------------
162+// Public API
163+// ---------------------------------------------------------------------------
164+
165+/** Preview an OpenAPI spec — extract metadata without registering anything.
166+ * Reuses parse() + extract() for the heavy lifting. */
167+export const previewSpec = Effect.fn("OpenApi.previewSpec")(function* (
168+ specText: string,
169+) {
170+ const doc = yield* parse(specText);
171+ const result = yield* extract(doc);
172+
173+ const securitySchemes = extractSecuritySchemes(
174+ doc.components?.securitySchemes ?? {},
175+ );
176+
177+ const rawSecurity = (doc.security ?? []) as Array<Record<string, unknown>>;
178+ const authStrategies = rawSecurity.map(
179+ (entry) => new AuthStrategy({ schemes: Object.keys(entry) }),
180+ );
181+
182+ return new SpecPreview({
183+ title: result.title,
184+ version: result.version,
185+ servers: result.servers as unknown as readonly unknown[],
186+ operationCount: result.operations.length,
187+ tags: collectTags(result),
188+ securitySchemes,
189+ authStrategies,
190+ headerPresets: buildHeaderPresets(securitySchemes, authStrategies),
191+ });
192+});
packages/plugins/openapi/src/sdk/real-specs.test.ts+225−0
@@ -0,0 +1,225 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { Effect, Option } from "effect";
3+import { readFileSync } from "node:fs";
4+import { resolve } from "node:path";
5+
6+import type { ParsedDocument } from "./parse";
7+import { parse } from "./parse";
8+import { extract } from "./extract";
9+import { previewSpec } from "./preview";
10+import type { ExtractionResult } from "./types";
11+import { createExecutor, makeTestConfig } from "@executor/sdk";
12+import { openApiPlugin } from "./plugin";
13+
14+// ---------------------------------------------------------------------------
15+// Load + parse once, share across tests
16+// ---------------------------------------------------------------------------
17+
18+const specPath = resolve(__dirname, "../../fixtures/cloudflare.json");
19+const specText = readFileSync(specPath, "utf-8");
20+
21+let cachedDoc: ParsedDocument | null = null;
22+let cachedResult: ExtractionResult | null = null;
23+
24+const getDoc = () =>
25+ Effect.gen(function* () {
26+ if (!cachedDoc) cachedDoc = yield* parse(specText);
27+ return cachedDoc;
28+ });
29+
30+const getResult = () =>
31+ Effect.gen(function* () {
32+ if (!cachedResult) {
33+ const doc = yield* getDoc();
34+ cachedResult = yield* extract(doc);
35+ }
36+ return cachedResult;
37+ });
38+
39+describe("Real specs: Cloudflare API", () => {
40+ it.effect("parses the full Cloudflare spec", () =>
41+ Effect.gen(function* () {
42+ const doc = yield* getDoc();
43+ expect(doc).toBeDefined();
44+ }),
45+ );
46+
47+ it.effect("extracts operations from the Cloudflare spec", () =>
48+ Effect.gen(function* () {
49+ const result = yield* getResult();
50+
51+ expect(Option.getOrElse(result.title, () => "")).toBe("Cloudflare API");
52+ expect(Option.getOrElse(result.version, () => "")).toBe("4.0.0");
53+ expect(result.operations.length).toBeGreaterThan(1000);
54+
55+ for (const op of result.operations) {
56+ expect(op.operationId).toBeTruthy();
57+ }
58+
59+ const validMethods = new Set([
60+ "get", "post", "put", "delete", "patch", "head", "options", "trace",
61+ ]);
62+ for (const op of result.operations) {
63+ expect(validMethods.has(op.method)).toBe(true);
64+ }
65+
66+ const zoneOps = result.operations.filter((op) =>
67+ op.pathTemplate.includes("/zones"),
68+ );
69+ expect(zoneOps.length).toBeGreaterThan(0);
70+
71+ const dnsOps = result.operations.filter((op) =>
72+ op.pathTemplate.includes("/dns_records"),
73+ );
74+ expect(dnsOps.length).toBeGreaterThan(0);
75+ }),
76+ );
77+
78+ it.effect("operations have input schemas", () =>
79+ Effect.gen(function* () {
80+ const result = yield* getResult();
81+
82+ const opsWithInput = result.operations.filter((op) =>
83+ Option.isSome(op.inputSchema),
84+ );
85+ expect(opsWithInput.length).toBeGreaterThan(500);
86+ }),
87+ );
88+
89+ it.effect("operations have output schemas", () =>
90+ Effect.gen(function* () {
91+ const result = yield* getResult();
92+
93+ const getOps = result.operations.filter((op) => op.method === "get");
94+ const getOpsWithOutput = getOps.filter((op) =>
95+ Option.isSome(op.outputSchema),
96+ );
97+ expect(getOpsWithOutput.length).toBeGreaterThan(getOps.length / 2);
98+ }),
99+ );
100+
101+ it.effect("registers all operations as tools via the plugin", () =>
102+ Effect.gen(function* () {
103+ const executor = yield* createExecutor(
104+ makeTestConfig({
105+ plugins: [openApiPlugin()] as const,
106+ }),
107+ );
108+
109+ const result = yield* executor.openapi.addSpec({
110+ spec: specText,
111+ namespace: "cloudflare",
112+ });
113+
114+ expect(result.toolCount).toBeGreaterThan(1000);
115+
116+ const tools = yield* executor.tools.list();
117+ expect(tools.length).toBe(result.toolCount + 2);
118+
119+ const cloudflareTools = tools.filter((tool) => tool.sourceId === "cloudflare");
120+ expect(cloudflareTools.length).toBe(result.toolCount);
121+
122+ for (const tool of cloudflareTools) {
123+ expect(tool.pluginKey).toBe("openapi");
124+ expect(tool.sourceId).toBe("cloudflare");
125+ }
126+
127+ const zoneTools = yield* executor.tools.list({ query: "zone" });
128+ expect(zoneTools.length).toBeGreaterThan(0);
129+ }),
130+ );
131+
132+ it.effect(
133+ "schema deduplication: definitions stored once, tools reference via $ref",
134+ () =>
135+ Effect.gen(function* () {
136+ const executor = yield* createExecutor(
137+ makeTestConfig({ plugins: [openApiPlugin()] as const }),
138+ );
139+
140+ yield* executor.openapi.addSpec({
141+ spec: specText,
142+ namespace: "cloudflare",
143+ });
144+
145+ const definitions = yield* executor.tools.definitions();
146+ expect(Object.keys(definitions).length).toBeGreaterThan(5000);
147+ expect(definitions["dns-records_dns_response_single"]).toBeDefined();
148+
149+ const tools = yield* executor.tools.list({ query: "dns" });
150+ expect(tools.length).toBeGreaterThan(0);
151+
152+ const schema = yield* executor.tools.schema(tools[0]!.id);
153+ const output = schema.outputSchema as Record<string, unknown>;
154+
155+ expect(output["$ref"]).toBeTypeOf("string");
156+ expect((output["$ref"] as string).startsWith("#/$defs/")).toBe(true);
157+
158+ expect(output["$defs"]).toBeDefined();
159+ const defs = output["$defs"] as Record<string, unknown>;
160+ expect(Object.keys(defs).length).toBeGreaterThan(0);
161+ expect(Object.keys(defs).length).toBeLessThan(100);
162+ }),
163+ );
164+
165+ it.effect("previewSpec returns security schemes and header presets", () =>
166+ Effect.gen(function* () {
167+ const preview = yield* previewSpec(specText);
168+
169+ expect(preview.operationCount).toBeGreaterThan(1000);
170+ expect(Option.isSome(preview.title)).toBe(true);
171+ expect(preview.servers.length).toBeGreaterThan(0);
172+
173+ // Cloudflare has 4 security schemes
174+ expect(preview.securitySchemes.length).toBe(4);
175+ const schemeNames = preview.securitySchemes.map((s) => s.name);
176+ expect(schemeNames).toContain("api_token");
177+ expect(schemeNames).toContain("api_key");
178+ expect(schemeNames).toContain("api_email");
179+
180+ // Should have header presets derived from security strategies
181+ expect(preview.headerPresets.length).toBeGreaterThan(0);
182+
183+ // Bearer token preset should include Authorization header
184+ const bearerPreset = preview.headerPresets.find((p) =>
185+ p.label.includes("Bearer"),
186+ );
187+ expect(bearerPreset).toBeDefined();
188+ expect(bearerPreset!.headers["Authorization"]).toBeNull(); // user must provide
189+ expect(bearerPreset!.secretHeaders).toContain("Authorization");
190+
191+ // API key + email preset
192+ const keyEmailPreset = preview.headerPresets.find((p) =>
193+ p.label.includes("api_email"),
194+ );
195+ expect(keyEmailPreset).toBeDefined();
196+ expect(keyEmailPreset!.headers["X-Auth-Email"]).toBeNull();
197+ expect(keyEmailPreset!.headers["X-Auth-Key"]).toBeNull();
198+ }),
199+ );
200+
201+ it.effect("removeSpec cleans up all Cloudflare tools", () =>
202+ Effect.gen(function* () {
203+ const executor = yield* createExecutor(
204+ makeTestConfig({
205+ plugins: [openApiPlugin()] as const,
206+ }),
207+ );
208+
209+ yield* executor.openapi.addSpec({
210+ spec: specText,
211+ namespace: "cloudflare",
212+ });
213+
214+ expect((yield* executor.tools.list()).length).toBeGreaterThan(0);
215+
216+ yield* executor.openapi.removeSpec("cloudflare");
217+
218+ const remaining = yield* executor.tools.list();
219+ expect(remaining.map((tool) => tool.id)).toEqual([
220+ "openapi.previewSpec",
221+ "openapi.addSource",
222+ ]);
223+ }),
224+ );
225+});
packages/plugins/openapi/src/sdk/types.ts+128−0
@@ -0,0 +1,128 @@
1+import { Schema } from "effect";
2+
3+// ---------------------------------------------------------------------------
4+// Branded IDs
5+// ---------------------------------------------------------------------------
6+
7+export const OperationId = Schema.String.pipe(Schema.brand("OperationId"));
8+export type OperationId = typeof OperationId.Type;
9+
10+// ---------------------------------------------------------------------------
11+// HTTP
12+// ---------------------------------------------------------------------------
13+
14+export const HttpMethod = Schema.Literal(
15+ "get", "put", "post", "delete", "patch", "head", "options", "trace",
16+);
17+export type HttpMethod = typeof HttpMethod.Type;
18+
19+export const ParameterLocation = Schema.Literal("path", "query", "header", "cookie");
20+export type ParameterLocation = typeof ParameterLocation.Type;
21+
22+// ---------------------------------------------------------------------------
23+// Extracted operation
24+// ---------------------------------------------------------------------------
25+
26+export class OperationParameter extends Schema.Class<OperationParameter>(
27+ "OperationParameter",
28+)({
29+ name: Schema.String,
30+ location: ParameterLocation,
31+ required: Schema.Boolean,
32+ schema: Schema.optionalWith(Schema.Unknown, { as: "Option" }),
33+ style: Schema.optionalWith(Schema.String, { as: "Option" }),
34+ explode: Schema.optionalWith(Schema.Boolean, { as: "Option" }),
35+ allowReserved: Schema.optionalWith(Schema.Boolean, { as: "Option" }),
36+ description: Schema.optionalWith(Schema.String, { as: "Option" }),
37+}) {}
38+
39+export class OperationRequestBody extends Schema.Class<OperationRequestBody>(
40+ "OperationRequestBody",
41+)({
42+ required: Schema.Boolean,
43+ contentType: Schema.String,
44+ schema: Schema.optionalWith(Schema.Unknown, { as: "Option" }),
45+}) {}
46+
47+export class ExtractedOperation extends Schema.Class<ExtractedOperation>(
48+ "ExtractedOperation",
49+)({
50+ operationId: OperationId,
51+ method: HttpMethod,
52+ pathTemplate: Schema.String,
53+ summary: Schema.optionalWith(Schema.String, { as: "Option" }),
54+ description: Schema.optionalWith(Schema.String, { as: "Option" }),
55+ tags: Schema.Array(Schema.String),
56+ parameters: Schema.Array(OperationParameter),
57+ requestBody: Schema.optionalWith(OperationRequestBody, { as: "Option" }),
58+ inputSchema: Schema.optionalWith(Schema.Unknown, { as: "Option" }),
59+ outputSchema: Schema.optionalWith(Schema.Unknown, { as: "Option" }),
60+ deprecated: Schema.optionalWith(Schema.Boolean, { default: () => false }),
61+}) {}
62+
63+export class ServerInfo extends Schema.Class<ServerInfo>("ServerInfo")({
64+ url: Schema.String,
65+ variables: Schema.optionalWith(
66+ Schema.Record({ key: Schema.String, value: Schema.String }),
67+ { as: "Option" },
68+ ),
69+}) {}
70+
71+export class ExtractionResult extends Schema.Class<ExtractionResult>(
72+ "ExtractionResult",
73+)({
74+ title: Schema.optionalWith(Schema.String, { as: "Option" }),
75+ version: Schema.optionalWith(Schema.String, { as: "Option" }),
76+ servers: Schema.Array(ServerInfo),
77+ operations: Schema.Array(ExtractedOperation),
78+}) {}
79+
80+// ---------------------------------------------------------------------------
81+// Operation binding — minimal invocation data (no schemas/metadata)
82+// ---------------------------------------------------------------------------
83+
84+export class OperationBinding extends Schema.Class<OperationBinding>(
85+ "OperationBinding",
86+)({
87+ method: HttpMethod,
88+ pathTemplate: Schema.String,
89+ parameters: Schema.Array(OperationParameter),
90+ requestBody: Schema.optionalWith(OperationRequestBody, { as: "Option" }),
91+}) {}
92+
93+// ---------------------------------------------------------------------------
94+// Invocation
95+// ---------------------------------------------------------------------------
96+
97+/**
98+ * A header value — either a static string or a reference to a secret.
99+ * Stored as JSON-serializable data.
100+ */
101+export const HeaderValue = Schema.Union(
102+ Schema.String,
103+ Schema.Struct({
104+ secretId: Schema.String,
105+ prefix: Schema.optional(Schema.String),
106+ }),
107+);
108+export type HeaderValue = typeof HeaderValue.Type;
109+
110+export class InvocationConfig extends Schema.Class<InvocationConfig>(
111+ "InvocationConfig",
112+)({
113+ baseUrl: Schema.String,
114+ /** Headers applied to every request. Values can reference secrets. */
115+ headers: Schema.optionalWith(
116+ Schema.Record({ key: Schema.String, value: HeaderValue }),
117+ { default: () => ({}) },
118+ ),
119+}) {}
120+
121+export class InvocationResult extends Schema.Class<InvocationResult>(
122+ "InvocationResult",
123+)({
124+ status: Schema.Number,
125+ headers: Schema.Record({ key: Schema.String, value: Schema.String }),
126+ data: Schema.NullOr(Schema.Unknown),
127+ error: Schema.NullOr(Schema.Unknown),
128+}) {}