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