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 | }) {} | ||