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 @@
1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2import { Schema } from "effect";
3import { ScopeId } from "@executor/sdk";
4
5import {
6 OpenApiParseError,
7 OpenApiExtractionError,
8} from "../sdk/errors";
9import { SpecPreview } from "../sdk/preview";
10
11// ---------------------------------------------------------------------------
12// Params
13// ---------------------------------------------------------------------------
14
15const scopeIdParam = HttpApiSchema.param("scopeId", ScopeId);
16
17// ---------------------------------------------------------------------------
18// Payloads
19// ---------------------------------------------------------------------------
20
21const AddSpecPayload = Schema.Struct({
22 spec: Schema.String,
23 baseUrl: Schema.optional(Schema.String),
24 namespace: Schema.optional(Schema.String),
25 headers: Schema.optional(
26 Schema.Record({ key: Schema.String, value: Schema.Unknown }),
27 ),
28});
29
30const PreviewSpecPayload = Schema.Struct({
31 spec: Schema.String,
32});
33
34// ---------------------------------------------------------------------------
35// Responses
36// ---------------------------------------------------------------------------
37
38const AddSpecResponse = Schema.Struct({
39 toolCount: Schema.Number,
40 namespace: Schema.String,
41});
42
43// ---------------------------------------------------------------------------
44// Errors with HTTP status
45// ---------------------------------------------------------------------------
46
47const ParseError = OpenApiParseError.annotations(
48 HttpApiSchema.annotations({ status: 400 }),
49);
50const ExtractionError = OpenApiExtractionError.annotations(
51 HttpApiSchema.annotations({ status: 400 }),
52);
53
54// ---------------------------------------------------------------------------
55// Group
56// ---------------------------------------------------------------------------
57
58export 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 @@
1import { HttpApiBuilder } from "@effect/platform";
2import { Context, Effect } from "effect";
3
4import { addGroup } from "@executor/api";
5import type { OpenApiPluginExtension, HeaderValue } from "../sdk/plugin";
6import { OpenApiGroup } from "./group";
7
8// ---------------------------------------------------------------------------
9// Service tag — the server provides the OpenAPI extension
10// ---------------------------------------------------------------------------
11
12export class OpenApiExtensionService extends Context.Tag(
13 "OpenApiExtensionService",
14)<OpenApiExtensionService, OpenApiPluginExtension>() {}
15
16// ---------------------------------------------------------------------------
17// Composed API — core + openapi group
18// ---------------------------------------------------------------------------
19
20const ExecutorApiWithOpenApi = addGroup(OpenApiGroup);
21
22// ---------------------------------------------------------------------------
23// Handlers
24// ---------------------------------------------------------------------------
25
26export const OpenApiHandlers = HttpApiBuilder.group(
27 ExecutorApiWithOpenApi,
28 "openapi",
29 (handlers) =>
30 handlers
31 .handle("previewSpec", ({ payload }) =>
32 Effect.gen(function* () {
33 const ext = yield* OpenApiExtensionService;
34 return yield* ext.previewSpec(payload.spec);
35 }).pipe(Effect.orDie),
36 )
37 .handle("addSpec", ({ payload }) =>
38 Effect.gen(function* () {
39 const ext = yield* OpenApiExtensionService;
40 const result = yield* ext.addSpec({
41 spec: payload.spec,
42 baseUrl: payload.baseUrl,
43 namespace: payload.namespace,
44 headers: payload.headers as Record<string, 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 @@
1export { OpenApiGroup } from "./group";
2export { OpenApiHandlers, OpenApiExtensionService } from "./handlers";
packages/plugins/openapi/src/react/AddOpenApiSource.tsx+760−0
@@ -0,0 +1,760 @@
1import { useState, useEffect, useRef } from "react";
2import { useAtomSet, useAtomValue, useAtomRefresh, Result } from "@effect-atom/atom-react";
3import { Option } from "effect";
4
5import {
6 secretsAtom,
7 setSecret,
8 resolveSecret,
9 useScope,
10 SecretPicker,
11 type SecretPickerSecret,
12} from "@executor/react";
13import { SecretId } from "@executor/sdk";
14import { Button } from "@executor/ui/components/button";
15import { Input } from "@executor/ui/components/input";
16import { Label } from "@executor/ui/components/label";
17import { Textarea } from "@executor/ui/components/textarea";
18import { Badge } from "@executor/ui/components/badge";
19import { RadioGroup, RadioGroupItem } from "@executor/ui/components/radio-group";
20import { Spinner } from "@executor/ui/components/spinner";
21import { previewOpenApiSpec, addOpenApiSpec } from "./atoms";
22import type { SpecPreview, HeaderPreset } from "../sdk/preview";
23import type { HeaderValue } from "../sdk/types";
24
25// ---------------------------------------------------------------------------
26// Inline secret creation
27// ---------------------------------------------------------------------------
28
29function 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
120type ResolveState =
121 | { status: "hidden" }
122 | { status: "loading" }
123 | { status: "revealed"; value: string }
124 | { status: "error" };
125
126function 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
200const 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
214function 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
332function 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
341function 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
351function 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
368export 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 @@
1export default function EditOpenApiSource(props: {
2 sourceId: string;
3 onSave: () => void;
4}) {
5 return (
6 <div>
7 <h3>Edit OpenAPI Source</h3>
8 <p>Source: {props.sourceId}</p>
9 {/* TODO: show spec info, auth config, operation list */}
10 <button onClick={props.onSave}>Save</button>
11 </div>
12 );
13}
packages/plugins/openapi/src/react/OpenApiSourceSummary.tsx+9−0
@@ -0,0 +1,9 @@
1export default function OpenApiSourceSummary(props: {
2 sourceId: string;
3}) {
4 return (
5 <span>
6 OpenAPI · {props.sourceId}
7 </span>
8 );
9}
packages/plugins/openapi/src/react/atoms.ts+12−0
@@ -0,0 +1,12 @@
1import { OpenApiClient } from "./client";
2
3// ---------------------------------------------------------------------------
4// Mutation atoms
5// ---------------------------------------------------------------------------
6
7export const previewOpenApiSpec = OpenApiClient.mutation(
8 "openapi",
9 "previewSpec",
10);
11
12export const addOpenApiSpec = OpenApiClient.mutation("openapi", "addSpec");
packages/plugins/openapi/src/react/client.ts+20−0
@@ -0,0 +1,20 @@
1import { AtomHttpApi } from "@effect-atom/atom-react";
2import { FetchHttpClient } from "@effect/platform";
3import { addGroup } from "@executor/api";
4import { getBaseUrl } from "@executor/react";
5import { OpenApiGroup } from "../api/group";
6
7// ---------------------------------------------------------------------------
8// OpenAPI-aware client — core routes + openapi routes
9// ---------------------------------------------------------------------------
10
11const OpenApiApi = addGroup(OpenApiGroup);
12
13export const OpenApiClient = AtomHttpApi.Tag<"OpenApiClient">()(
14 "OpenApiClient",
15 {
16 api: OpenApiApi,
17 httpClient: FetchHttpClient.layer,
18 baseUrl: getBaseUrl(),
19 },
20);
packages/plugins/openapi/src/react/index.ts+6−0
@@ -0,0 +1,6 @@
1export { openApiSourcePlugin } from "./source-plugin";
2export { OpenApiClient } from "./client";
3export {
4 previewOpenApiSpec,
5 addOpenApiSpec,
6} from "./atoms";
packages/plugins/openapi/src/react/source-plugin.ts+12−0
@@ -0,0 +1,12 @@
1import { lazy } from "react";
2import type { SourcePlugin } from "@executor/react";
3import { openApiPresets } from "../sdk/presets";
4
5export 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
9import type { ExtractedOperation } from "./types";
10
11// ---------------------------------------------------------------------------
12// Word / case utilities
13// ---------------------------------------------------------------------------
14
15const 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
24const normalizeWord = (value: string): string => value.toLowerCase();
25
26const 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
33const 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
42const VERSION_SEGMENT_REGEX = /^v\d+(?:[._-]\d+)?$/i;
43const IGNORED_PATH_SEGMENTS = new Set(["api"]);
44
45const pathSegmentsFromTemplate = (pathTemplate: string): string[] =>
46 pathTemplate
47 .split("/")
48 .map((s) => s.trim())
49 .filter((s) => s.length > 0);
50
51const isPathParameterSegment = (segment: string): boolean =>
52 segment.startsWith("{") && segment.endsWith("}");
53
54const 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
64const deriveVersionSegment = (pathTemplate: string): string | undefined =>
65 pathSegmentsFromTemplate(pathTemplate)
66 .map((s) => s.toLowerCase())
67 .find((s) => VERSION_SEGMENT_REGEX.test(s));
68
69const 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
80const splitOperationIdSegments = (value: string): string[] =>
81 value
82 .split(/[/.]+/)
83 .map((s) => s.trim())
84 .filter((s) => s.length > 0);
85
86const 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
100const 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
116const 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
131export 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
148const 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
208const 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 */
225export 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 @@
1import { Schema } from "effect";
2
3export class OpenApiParseError extends Schema.TaggedError<OpenApiParseError>()(
4 "OpenApiParseError",
5 {
6 message: Schema.String,
7 error: Schema.Defect,
8 },
9) {}
10
11export class OpenApiExtractionError extends Schema.TaggedError<OpenApiExtractionError>()(
12 "OpenApiExtractionError",
13 {
14 message: Schema.String,
15 },
16) {}
17
18export 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 @@
1import { Effect, Option } from "effect";
2
3import { OpenApiExtractionError } from "./errors";
4import type { ParsedDocument } from "./parse";
5import {
6 DocResolver,
7 preferredContent,
8 type OperationObject,
9 type ParameterObject,
10 type PathItemObject,
11 type RequestBodyObject,
12 type ResponseObject,
13} from "./openapi-utils";
14import {
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
29const HTTP_METHODS: readonly HttpMethod[] = [
30 "get", "put", "post", "delete", "patch", "head", "options", "trace",
31];
32
33const VALID_PARAM_LOCATIONS = new Set<string>(["path", "query", "header", "cookie"]);
34
35// ---------------------------------------------------------------------------
36// Parameter extraction
37// ---------------------------------------------------------------------------
38
39const 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
80const 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
103const 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
129const 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
160const 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
175const 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 */
201export 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 @@
1import { describe, expect, it } from "@effect/vitest";
2import {
3 HttpApi,
4 HttpApiEndpoint,
5 HttpApiGroup,
6 OpenApi,
7} from "@effect/platform";
8import { Effect, Option, Schema } from "effect";
9
10import { parse } from "./parse";
11import { extract } from "./extract";
12import { compileToolDefinitions } from "./definitions";
13
14// ---------------------------------------------------------------------------
15// Define a test API using Effect's HttpApi
16// ---------------------------------------------------------------------------
17
18class Pet extends Schema.Class<Pet>("Pet")({
19 id: Schema.Number,
20 name: Schema.String,
21 tag: Schema.optional(Schema.String),
22}) {}
23
24class CreatePetInput extends Schema.Class<CreatePetInput>("CreatePetInput")({
25 name: Schema.String,
26 tag: Schema.optional(Schema.String),
27}) {}
28
29class PetNotFound extends Schema.TaggedError<PetNotFound>()("PetNotFound", {
30 message: Schema.String,
31}) {}
32
33const 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
49const PetstoreApi = HttpApi.make("petstore").add(PetstoreGroup);
50
51// Generate OpenAPI spec from the Effect API definition
52const spec = OpenApi.fromApi(PetstoreApi);
53
54// ---------------------------------------------------------------------------
55// Tests
56// ---------------------------------------------------------------------------
57
58describe("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 @@
1export { parse } from "./parse";
2export { extract } from "./extract";
3export { invoke, makeOpenApiInvoker } from "./invoke";
4export {
5 openApiPlugin,
6 type OpenApiSpecConfig,
7 type OpenApiPluginExtension,
8} from "./plugin";
9export {
10 type OpenApiOperationStore,
11 type StoredSource,
12 type SourceConfig,
13} from "./operation-store";
14export {
15 makeKvOperationStore,
16 makeInMemoryOperationStore,
17} from "./kv-operation-store";
18export {
19 previewSpec,
20 SecurityScheme,
21 AuthStrategy,
22 HeaderPreset,
23 SpecPreview,
24} from "./preview";
25export { DocResolver, resolveBaseUrl, preferredContent } from "./openapi-utils";
26
27export {
28 OpenApiParseError,
29 OpenApiExtractionError,
30 OpenApiInvocationError,
31} from "./errors";
32
33export {
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 @@
1import { Effect, Layer, Option } from "effect";
2import {
3 HttpClient,
4 HttpClientRequest,
5} from "@effect/platform";
6
7import {
8 type ToolId,
9 type ToolInvoker,
10 ToolInvocationResult,
11 ToolInvocationError,
12 type ScopeId,
13 type SecretId,
14} from "@executor/sdk";
15
16import { OpenApiInvocationError } from "./errors";
17import type { OpenApiOperationStore } from "./operation-store";
18import {
19 type HeaderValue,
20 type OperationBinding,
21 InvocationConfig,
22 InvocationResult,
23 type OperationParameter,
24} from "./types";
25
26// ---------------------------------------------------------------------------
27// Parameter reading
28// ---------------------------------------------------------------------------
29
30const 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
37const 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
63const 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
125const 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
151const 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
166const 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. */
181export 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
281const SAFE_METHODS = new Set(["get", "head", "options"]);
282
283/**
284 * Derive tool annotations from the HTTP method and path.
285 */
286export 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
297export 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
7import { Effect, Schema } from "effect";
8import { scopeKv, makeInMemoryScopedKv, type Kv, type ToolId, type ScopedKv } from "@executor/sdk";
9
10import type { OpenApiOperationStore, StoredSource } from "./operation-store";
11import { OperationBinding, InvocationConfig, HeaderValue } from "./types";
12
13// ---------------------------------------------------------------------------
14// Stored schemas
15// ---------------------------------------------------------------------------
16
17class StoredEntry extends Schema.Class<StoredEntry>("StoredEntry")({
18 namespace: Schema.String,
19 binding: OperationBinding,
20 config: InvocationConfig,
21}) {}
22
23const encodeEntry = Schema.encodeSync(Schema.parseJson(StoredEntry));
24const decodeEntry = Schema.decodeUnknownSync(Schema.parseJson(StoredEntry));
25
26const 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});
38const encodeSource = Schema.encodeSync(Schema.parseJson(StoredSourceSchema));
39const decodeSource = Schema.decodeUnknownSync(Schema.parseJson(StoredSourceSchema));
40
41// ---------------------------------------------------------------------------
42// Implementation
43// ---------------------------------------------------------------------------
44
45const 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
107export const makeKvOperationStore = (
108 kv: Kv,
109 namespace: string,
110): OpenApiOperationStore =>
111 makeStore(
112 scopeKv(kv, `${namespace}.bindings`),
113 scopeKv(kv, `${namespace}.sources`),
114 );
115
116export 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
7import { Option } from "effect";
8import type { OpenAPIV3, OpenAPIV3_1 } from "openapi-types";
9import type { ParsedDocument } from "./parse";
10
11// ---------------------------------------------------------------------------
12// Type aliases — collapse V3 / V3_1 unions into single names
13// ---------------------------------------------------------------------------
14
15export type ParameterObject = OpenAPIV3.ParameterObject | OpenAPIV3_1.ParameterObject;
16export type OperationObject = OpenAPIV3.OperationObject | OpenAPIV3_1.OperationObject;
17export type PathItemObject = OpenAPIV3.PathItemObject | OpenAPIV3_1.PathItemObject;
18export type RequestBodyObject = OpenAPIV3.RequestBodyObject | OpenAPIV3_1.RequestBodyObject;
19export type ResponseObject = OpenAPIV3.ResponseObject | OpenAPIV3_1.ResponseObject;
20export type MediaTypeObject = OpenAPIV3.MediaTypeObject | OpenAPIV3_1.MediaTypeObject;
21
22// ---------------------------------------------------------------------------
23// DocResolver — wraps a parsed document for clean $ref resolution
24// ---------------------------------------------------------------------------
25
26export 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
50const isRef = (value: unknown): value is { $ref: string } =>
51 typeof value === "object" && value !== null && "$ref" in value;
52
53// ---------------------------------------------------------------------------
54// Server URL resolution
55// ---------------------------------------------------------------------------
56
57export 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) */
77export 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 @@
1import type { Effect } from "effect";
2import type { ToolId } from "@executor/sdk";
3
4import type { OperationBinding, InvocationConfig, HeaderValue } from "./types";
5
6// ---------------------------------------------------------------------------
7// Operation store — plugin's own storage for invocation data
8// ---------------------------------------------------------------------------
9
10export interface SourceConfig {
11 readonly spec: string;
12 readonly baseUrl?: string;
13 readonly namespace?: string;
14 readonly headers?: Record<string, HeaderValue>;
15}
16
17export interface StoredSource {
18 readonly namespace: string;
19 readonly name: string;
20 readonly config: SourceConfig;
21}
22
23export 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 @@
1import SwaggerParser from "@apidevtools/swagger-parser";
2import type { OpenAPI, OpenAPIV3, OpenAPIV3_1 } from "openapi-types";
3import { Effect } from "effect";
4
5import { OpenApiParseError } from "./errors";
6
7export type ParsedDocument = OpenAPIV3.Document | OpenAPIV3_1.Document;
8
9/** Parse, validate, and bundle an OpenAPI document from text or URL */
10export 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
42import YAML from "yaml";
43import { OpenApiExtractionError } from "./errors";
44
45// swagger-parser's dereference needs a tagged error for this path
46class OpenApiExtractionErrorFromParse extends OpenApiExtractionError {}
47
48const 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
53const 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 @@
1import { expect, layer } from "@effect/vitest";
2import { Effect, Layer, Schema } from "effect";
3import {
4 HttpApi,
5 HttpApiBuilder,
6 HttpApiEndpoint,
7 HttpApiGroup,
8 HttpClient,
9 HttpServerRequest,
10 OpenApi,
11} from "@effect/platform";
12import { NodeHttpServer } from "@effect/platform-node";
13
14import {
15 createExecutor,
16 makeInMemoryPolicyEngine,
17 makeInMemorySecretStore,
18 makeInMemorySourceRegistry,
19 makeInMemoryToolRegistry,
20 makeTestConfig,
21 ScopeId,
22 SecretId,
23 type InvokeOptions,
24} from "@executor/sdk";
25import { openApiPlugin } from "./plugin";
26
27const autoApprove: InvokeOptions = { onElicitation: "accept-all" };
28
29// ---------------------------------------------------------------------------
30// Define a test API with Effect HttpApi
31// ---------------------------------------------------------------------------
32
33class Item extends Schema.Class<Item>("Item")({
34 id: Schema.Number,
35 name: Schema.String,
36}) {}
37
38class EchoHeaders extends Schema.Class<EchoHeaders>("EchoHeaders")({
39 authorization: Schema.optional(Schema.String),
40 "x-static": Schema.optional(Schema.String),
41}) {}
42
43const 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
56const TestApi = HttpApi.make("testApi").add(ItemsGroup);
57
58const spec = OpenApi.fromApi(TestApi);
59const specJson = JSON.stringify(spec);
60
61// ---------------------------------------------------------------------------
62// Implement handlers
63// ---------------------------------------------------------------------------
64
65const ITEMS = [
66 { id: 1, name: "Widget" },
67 { id: 2, name: "Gadget" },
68 { id: 3, name: "Doohickey" },
69];
70
71const 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
97const ApiLive = HttpApiBuilder.api(TestApi).pipe(
98 Layer.provide(ItemsGroupLive),
99);
100
101const 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
110layer(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 @@
1import { Effect, Option, Schema } from "effect";
2import { FetchHttpClient, HttpClient } from "@effect/platform";
3import type { Layer } from "effect";
4
5import {
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
17import { parse } from "./parse";
18import { extract } from "./extract";
19import { compileToolDefinitions, type ToolDefinition } from "./definitions";
20import { makeOpenApiInvoker } from "./invoke";
21import { resolveBaseUrl } from "./openapi-utils";
22import type { OpenApiOperationStore } from "./operation-store";
23import { makeInMemoryOperationStore } from "./kv-operation-store";
24import { previewSpec, SpecPreview } from "./preview";
25import {
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 */
37export type HeaderValue = HeaderValueValue;
38
39export 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
51export 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
68const PreviewSpecInputSchema = Schema.Struct({
69 spec: Schema.String,
70});
71type PreviewSpecInput = typeof PreviewSpecInputSchema.Type;
72
73const 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});
81type AddSourceInput = typeof AddSourceInputSchema.Type;
82
83const AddSourceOutputSchema = Schema.Struct({
84 sourceId: Schema.String,
85 toolCount: Schema.Number,
86});
87
88/** Rewrite OpenAPI `#/components/schemas/X` refs to standard `#/$defs/X`. */
89const 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
119const 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
141const 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
153export 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 @@
1export interface OpenApiPreset {
2 readonly id: string;
3 readonly name: string;
4 readonly summary: string;
5 readonly url: string;
6 readonly icon?: string;
7}
8
9export 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 @@
1import { Effect, Option } from "effect";
2import { Schema } from "effect";
3
4import { parse } from "./parse";
5import { extract } from "./extract";
6import type { ExtractionResult } from "./types";
7
8// ---------------------------------------------------------------------------
9// Security scheme — what the spec declares it needs
10// ---------------------------------------------------------------------------
11
12export 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
30export 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
39export 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
52export 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
70const 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
99const 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
153const 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. */
167export 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 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Effect, Option } from "effect";
3import { readFileSync } from "node:fs";
4import { resolve } from "node:path";
5
6import type { ParsedDocument } from "./parse";
7import { parse } from "./parse";
8import { extract } from "./extract";
9import { previewSpec } from "./preview";
10import type { ExtractionResult } from "./types";
11import { createExecutor, makeTestConfig } from "@executor/sdk";
12import { openApiPlugin } from "./plugin";
13
14// ---------------------------------------------------------------------------
15// Load + parse once, share across tests
16// ---------------------------------------------------------------------------
17
18const specPath = resolve(__dirname, "../../fixtures/cloudflare.json");
19const specText = readFileSync(specPath, "utf-8");
20
21let cachedDoc: ParsedDocument | null = null;
22let cachedResult: ExtractionResult | null = null;
23
24const getDoc = () =>
25 Effect.gen(function* () {
26 if (!cachedDoc) cachedDoc = yield* parse(specText);
27 return cachedDoc;
28 });
29
30const getResult = () =>
31 Effect.gen(function* () {
32 if (!cachedResult) {
33 const doc = yield* getDoc();
34 cachedResult = yield* extract(doc);
35 }
36 return cachedResult;
37 });
38
39describe("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 @@
1import { Schema } from "effect";
2
3// ---------------------------------------------------------------------------
4// Branded IDs
5// ---------------------------------------------------------------------------
6
7export const OperationId = Schema.String.pipe(Schema.brand("OperationId"));
8export type OperationId = typeof OperationId.Type;
9
10// ---------------------------------------------------------------------------
11// HTTP
12// ---------------------------------------------------------------------------
13
14export const HttpMethod = Schema.Literal(
15 "get", "put", "post", "delete", "patch", "head", "options", "trace",
16);
17export type HttpMethod = typeof HttpMethod.Type;
18
19export const ParameterLocation = Schema.Literal("path", "query", "header", "cookie");
20export type ParameterLocation = typeof ParameterLocation.Type;
21
22// ---------------------------------------------------------------------------
23// Extracted operation
24// ---------------------------------------------------------------------------
25
26export 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
39export 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
47export 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
63export 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
71export 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
84export 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 */
101export const HeaderValue = Schema.Union(
102 Schema.String,
103 Schema.Struct({
104 secretId: Schema.String,
105 prefix: Schema.optional(Schema.String),
106 }),
107);
108export type HeaderValue = typeof HeaderValue.Type;
109
110export 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
121export 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}) {}