History

openapi works not bad

Rhys Sullivan committed 7 months ago

Commit a9739d61 parent c45a85b20 files · +4934 −159Browse files

Unified · Split

20 files
  1. .executor/executor.jsonc +2 −8
  2. .gitignore +2 −1
  3. apps/web/src/source-plugins/pages.tsx +1 −1
  4. bun.lock +4 −1
  5. packages/platform/sdk/src/executor-effect.ts +13 −4
  6. packages/platform/sdk/src/plugins.ts +3 −0
  7. packages/platform/sdk/src/sources/operations.ts +114 −0
  8. plugins/openapi/http/index.ts +91 −0
  9. plugins/openapi/react/index.tsx +1116 −125
  10. plugins/openapi/sdk/catalog.ts +919 −0
  11. plugins/openapi/sdk/definitions.ts +249 −0
  12. plugins/openapi/sdk/document.ts +32 −0
  13. plugins/openapi/sdk/extraction.ts +886 −0
  14. plugins/openapi/sdk/http-serialization.ts +568 −0
  15. plugins/openapi/sdk/index.ts +513 −13
  16. plugins/openapi/sdk/package.json +5 −2
  17. plugins/openapi/sdk/tsconfig.json +1 −3
  18. plugins/openapi/sdk/types.ts +298 −0
  19. plugins/openapi/shared/index.ts +97 −1
  20. ralph.ts +20 −0
.executor/executor.jsonc+2−8
@@ -1,14 +1,8 @@
11 {
22 "sources": {
3− "github": {
3+ "vercel-api": {
44 "kind": "openapi",
5− "connection": {
6− "endpoint": "https://raw.githubusercontent.com/github/rest-api-description/refs/heads/main/descriptions/ghes-3.9/%7Bprotocol%7D://%7Bhostname%7D/api/v3"
7− },
8− "binding": {
9− "specUrl": "https://raw.githubusercontent.com/github/rest-api-description/refs/heads/main/descriptions/ghes-3.9/ghes-3.9.yaml",
10− "defaultHeaders": null
11− }
5+ "name": "Vercel API"
126 }
137 }
148 }
.gitignore+2−1
@@ -83,4 +83,5 @@ sources/composio-scrape/data/ · 82 unmodified lines
8383 personal-notes/
8484 .tmp/
8585 executor/.env.executor-push
86−ignored/
86+ignored/
87+agent-notes/
apps/web/src/source-plugins/pages.tsx+1−1
@@ -105,7 +105,7 @@ export function SourcePluginDetailPage(input: { · 104 unmodified lines
105105 }
106106
107107 return (
108− <div className="mx-auto max-w-4xl px-6 py-10 lg:px-10 lg:py-14">
108+ <div className="h-full min-h-0">
109109 {definition.renderDetailPage({
110110 source: loadedSource,
111111 route: {
bun.lock+4−1
@@ -146,12 +146,12 @@ · 145 unmodified lines
146146 "dependencies": {
147147 "@effect-atom/atom": "0.5.3",
148148 "@effect-atom/atom-react": "0.5.0",
149+ "@effect/platform": "catalog:",
149150 "@executor/platform-api": "workspace:*",
150151 "@executor/platform-sdk": "workspace:*",
151152 "effect": "catalog:",
152153 },
153154 "devDependencies": {
154− "@effect/platform": "catalog:",
155155 "@effect/platform-node": "catalog:",
156156 "@effect/vitest": "catalog:",
157157 "@executor/effect-test-utils": "workspace:*",
@@ -460,10 +460,13 @@ · 302 unmodified lines
460460 "name": "@executor/plugin-openapi-sdk",
461461 "version": "0.0.0",
462462 "dependencies": {
463+ "@executor/codemode-core": "workspace:*",
464+ "@executor/ir": "workspace:*",
463465 "@executor/platform-sdk": "workspace:*",
464466 "@executor/plugin-openapi-shared": "workspace:*",
465467 "@executor/source-core": "workspace:*",
466468 "effect": "catalog:",
469+ "yaml": "^2.8.1",
467470 },
468471 "devDependencies": {
469472 "@types/node": "catalog:",
packages/platform/sdk/src/executor-effect.ts+13−4
@@ -48,11 +48,12 @@ import { · 47 unmodified lines
4848 getSourceInspectionToolDetail,
4949 } from "./sources/inspection";
5050 import {
51− createManagedSource,
51+ createManagedSourceRecord,
5252 getSource,
53+ refreshManagedSourceCatalog,
5354 listSources,
5455 removeSource,
55− saveManagedSource,
56+ saveManagedSourceRecord,
5657 } from "./sources/operations";
5758 import type { ExecutorBackend } from "./backend";
5859 import {
@@ -288,7 +289,7 @@ export const createExecutorEffect = < · 229 unmodified lines
288289 sources: {
289290 create: ({ source }) =>
290291 providePluginHostEffect(
291− createManagedSource({
292+ createManagedSourceRecord({
292293 scopeId: executor.scopeId,
293294 actorScopeId: executor.actorScopeId,
294295 source,
@@ -304,11 +305,19 @@ export const createExecutorEffect = < · 9 unmodified lines
304305 ),
305306 save: (source) =>
306307 providePluginHostEffect(
307− saveManagedSource({
308+ saveManagedSourceRecord({
308309 actorScopeId: executor.actorScopeId,
309310 source,
310311 }),
311312 ),
313+ refreshCatalog: (sourceId) =>
314+ providePluginHostEffect(
315+ refreshManagedSourceCatalog({
316+ scopeId: executor.scopeId,
317+ sourceId,
318+ actorScopeId: executor.actorScopeId,
319+ }),
320+ ),
312321 remove: (sourceId) =>
313322 providePluginHostEffect(
314323 removeSource({
packages/platform/sdk/src/plugins.ts+3−0
@@ -34,6 +34,9 @@ export type ExecutorSdkPluginHost = { · 33 unmodified lines
3434 }) => Effect.Effect<ExecutorSource, Error, never>;
3535 get: (sourceId: ExecutorSource["id"]) => Effect.Effect<ExecutorSource, Error, never>;
3636 save: (source: ExecutorSource) => Effect.Effect<ExecutorSource, Error, never>;
37+ refreshCatalog: (
38+ sourceId: ExecutorSource["id"],
39+ ) => Effect.Effect<ExecutorSource, Error, never>;
3740 remove: (sourceId: ExecutorSource["id"]) => Effect.Effect<boolean, Error, never>;
3841 };
3942 };
packages/platform/sdk/src/sources/operations.ts+114−0
@@ -156,6 +156,42 @@ export const createManagedSource = (input: { · 155 unmodified lines
156156 }),
157157 );
158158
159+export const createManagedSourceRecord = (input: {
160+ scopeId: ScopeId;
161+ actorScopeId: ScopeId;
162+ source: Omit<
163+ Source,
164+ "id" | "scopeId" | "createdAt" | "updatedAt"
165+ >;
166+}) =>
167+ Effect.flatMap(ExecutorStateStore, () =>
168+ Effect.gen(function* () {
169+ const sourceStore = yield* RuntimeSourceStoreService;
170+ const now = Date.now();
171+
172+ const source = yield* normalizeSourceForCreate({
173+ scopeId: input.scopeId,
174+ sourceId: SourceIdSchema.make(`src_${crypto.randomUUID()}`),
175+ source: input.source,
176+ now,
177+ }).pipe(
178+ Effect.mapError((cause) =>
179+ sourceOps.create.badRequest(
180+ "Invalid source definition",
181+ cause instanceof Error ? cause.message : String(cause),
182+ ),
183+ ),
184+ );
185+
186+ return yield* mapPersistenceError(
187+ sourceOps.create.child("persist"),
188+ sourceStore.persistSource(source, {
189+ actorScopeId: input.actorScopeId,
190+ }),
191+ );
192+ }),
193+ );
194+
159195 export const getSource = (input: {
160196 scopeId: ScopeId;
161197 sourceId: SourceId;
@@ -224,6 +260,84 @@ export const saveManagedSource = (input: { · 62 unmodified lines
224260 }),
225261 );
226262
263+export const saveManagedSourceRecord = (input: {
264+ actorScopeId: ScopeId;
265+ source: Source;
266+}) =>
267+ Effect.flatMap(ExecutorStateStore, () =>
268+ Effect.gen(function* () {
269+ const sourceStore = yield* RuntimeSourceStoreService;
270+ const updatedSource = yield* normalizeSourceForSave({
271+ source: input.source,
272+ now: Date.now(),
273+ }).pipe(
274+ Effect.mapError((cause) =>
275+ sourceOps.update.badRequest(
276+ "Invalid source definition",
277+ cause instanceof Error ? cause.message : String(cause),
278+ ),
279+ ),
280+ );
281+
282+ return yield* mapPersistenceError(
283+ sourceOps.update.child("persist"),
284+ sourceStore.persistSource(updatedSource, {
285+ actorScopeId: input.actorScopeId,
286+ }),
287+ );
288+ }),
289+ );
290+
291+export const refreshManagedSourceCatalog = (input: {
292+ scopeId: ScopeId;
293+ sourceId: SourceId;
294+ actorScopeId: ScopeId;
295+}) =>
296+ Effect.flatMap(ExecutorStateStore, (store) =>
297+ Effect.gen(function* () {
298+ const sourceStore = yield* RuntimeSourceStoreService;
299+ const source = yield* sourceStore.loadSourceById({
300+ scopeId: input.scopeId,
301+ sourceId: input.sourceId,
302+ actorScopeId: input.actorScopeId,
303+ }).pipe(
304+ Effect.mapError((cause) =>
305+ cause instanceof Error &&
306+ cause.message.startsWith("Source not found:")
307+ ? sourceOps.get.notFound(
308+ "Source not found",
309+ `scopeId=${input.scopeId} sourceId=${input.sourceId}`,
310+ )
311+ : sourceOps.get.unknownStorage(
312+ cause,
313+ "Failed projecting stored source",
314+ ),
315+ ),
316+ );
317+
318+ yield* syncArtifactsForSource({
319+ store,
320+ sourceStore,
321+ source,
322+ actorScopeId: input.actorScopeId,
323+ operation: sourceOps.update,
324+ });
325+
326+ return yield* sourceStore.loadSourceById({
327+ scopeId: input.scopeId,
328+ sourceId: input.sourceId,
329+ actorScopeId: input.actorScopeId,
330+ }).pipe(
331+ Effect.mapError((cause) =>
332+ sourceOps.update.unknownStorage(
333+ cause,
334+ "Failed loading refreshed source",
335+ ),
336+ ),
337+ );
338+ }),
339+ );
340+
227341 export const removeSource = (input: {
228342 scopeId: ScopeId;
229343 sourceId: SourceId;
plugins/openapi/http/index.ts+91−0
@@ -6,6 +6,7 @@ import { · 5 unmodified lines
66 HttpApiSchema,
77 } from "@effect/platform";
88 import {
9+ ControlPlaneNotFoundError,
910 ControlPlaneBadRequestError,
1011 ControlPlaneForbiddenError,
1112 ControlPlaneStorageError,
@@ -15,18 +16,24 @@ import { · 3 unmodified lines
1516 import { resolveRequestedLocalWorkspace } from "@executor/platform-api/local-context";
1617 import {
1718 ScopeIdSchema,
19+ SourceIdSchema,
1820 SourceSchema,
1921 type Source,
2022 } from "@executor/platform-sdk/schema";
2123 import * as Effect from "effect/Effect";
24+import * as Schema from "effect/Schema";
2225
2326 import {
2427 OpenApiConnectInputSchema,
2528 OpenApiPreviewRequestSchema,
2629 OpenApiPreviewResponseSchema,
30+ OpenApiSourceConfigPayloadSchema,
31+ OpenApiUpdateSourceInputSchema,
2732 type OpenApiConnectInput,
2833 type OpenApiPreviewRequest,
2934 type OpenApiPreviewResponse,
35+ type OpenApiSourceConfigPayload,
36+ type OpenApiUpdateSourceInput,
3037 } from "@executor/plugin-openapi-shared";
3138
3239 type OpenApiExecutorExtension = {
@@ -37,10 +44,20 @@ type OpenApiExecutorExtension = { · 4 unmodified lines
3744 createSource: (
3845 input: OpenApiConnectInput,
3946 ) => Effect.Effect<Source, Error, never>;
47+ getSourceConfig: (
48+ sourceId: Source["id"],
49+ ) => Effect.Effect<OpenApiSourceConfigPayload, Error, never>;
50+ updateSource: (
51+ input: OpenApiUpdateSourceInput,
52+ ) => Effect.Effect<Source, Error, never>;
53+ removeSource: (
54+ sourceId: Source["id"],
55+ ) => Effect.Effect<boolean, Error, never>;
4056 };
4157 };
4258
4359 const workspaceIdParam = HttpApiSchema.param("workspaceId", ScopeIdSchema);
60+const sourceIdParam = HttpApiSchema.param("sourceId", SourceIdSchema);
4461
4562 export const OpenApiHttpGroup = HttpApiGroup.make("openapi")
4663 .add(
@@ -59,6 +76,31 @@ export const OpenApiHttpGroup = HttpApiGroup.make("openapi") · 12 unmodified lines
5976 .addError(ControlPlaneForbiddenError)
6077 .addError(ControlPlaneStorageError),
6178 )
79+ .add(
80+ HttpApiEndpoint.get("getSourceConfig")`/workspaces/${workspaceIdParam}/plugins/openapi/sources/${sourceIdParam}`
81+ .addSuccess(OpenApiSourceConfigPayloadSchema)
82+ .addError(ControlPlaneBadRequestError)
83+ .addError(ControlPlaneForbiddenError)
84+ .addError(ControlPlaneNotFoundError)
85+ .addError(ControlPlaneStorageError),
86+ )
87+ .add(
88+ HttpApiEndpoint.put("updateSource")`/workspaces/${workspaceIdParam}/plugins/openapi/sources/${sourceIdParam}`
89+ .setPayload(OpenApiSourceConfigPayloadSchema)
90+ .addSuccess(SourceSchema)
91+ .addError(ControlPlaneBadRequestError)
92+ .addError(ControlPlaneForbiddenError)
93+ .addError(ControlPlaneNotFoundError)
94+ .addError(ControlPlaneStorageError),
95+ )
96+ .add(
97+ HttpApiEndpoint.del("removeSource")`/workspaces/${workspaceIdParam}/plugins/openapi/sources/${sourceIdParam}`
98+ .addSuccess(Schema.Struct({ removed: Schema.Boolean }))
99+ .addError(ControlPlaneBadRequestError)
100+ .addError(ControlPlaneForbiddenError)
101+ .addError(ControlPlaneNotFoundError)
102+ .addError(ControlPlaneStorageError),
103+ )
62104 .prefix("/v1");
63105
64106 export const OpenApiHttpApi = HttpApi.make("executor").add(OpenApiHttpGroup);
@@ -82,6 +124,22 @@ const toStorageError = (operation: string) => (cause: unknown) => · 17 unmodified lines
82124 details: cause instanceof Error ? cause.stack ?? cause.message : String(cause),
83125 });
84126
127+const toNotFoundError = (operation: string, cause: unknown) =>
128+ new ControlPlaneNotFoundError({
129+ operation,
130+ message: cause instanceof Error ? cause.message : String(cause),
131+ details: cause instanceof Error ? cause.stack ?? cause.message : String(cause),
132+ });
133+
134+const mapPluginStorageError = (operation: string) => (cause: unknown) => {
135+ const message = cause instanceof Error ? cause.message : String(cause);
136+ if (message.includes("not found") || message.includes("Not found")) {
137+ return toNotFoundError(operation, cause);
138+ }
139+
140+ return toStorageError(operation)(cause);
141+};
142+
85143 export const openApiHttpPlugin = (): ExecutorHttpPlugin<
86144 typeof OpenApiHttpGroup,
87145 OpenApiExecutorExtension
@@ -109,5 +167,38 @@ export const openApiHttpPlugin = (): ExecutorHttpPlugin< · 21 unmodified lines
109167 Effect.mapError(toStorageError("openapi.createSource")),
110168 )
111169 )
170+ .handle("getSourceConfig", ({ path }) =>
171+ resolveRequestedLocalWorkspace(
172+ "openapi.getSourceConfig",
173+ path.workspaceId,
174+ ).pipe(
175+ Effect.flatMap(() => executor.openapi.getSourceConfig(path.sourceId)),
176+ Effect.mapError(mapPluginStorageError("openapi.getSourceConfig")),
177+ )
178+ )
179+ .handle("updateSource", ({ path, payload }) =>
180+ resolveRequestedLocalWorkspace(
181+ "openapi.updateSource",
182+ path.workspaceId,
183+ ).pipe(
184+ Effect.flatMap(() =>
185+ executor.openapi.updateSource({
186+ sourceId: path.sourceId,
187+ config: payload,
188+ })
189+ ),
190+ Effect.mapError(mapPluginStorageError("openapi.updateSource")),
191+ )
192+ )
193+ .handle("removeSource", ({ path }) =>
194+ resolveRequestedLocalWorkspace(
195+ "openapi.removeSource",
196+ path.workspaceId,
197+ ).pipe(
198+ Effect.flatMap(() => executor.openapi.removeSource(path.sourceId)),
199+ Effect.map((removed) => ({ removed })),
200+ Effect.mapError(mapPluginStorageError("openapi.removeSource")),
201+ )
202+ )
112203 ),
113204 });
plugins/openapi/react/index.tsx+1116−125
@@ -1,4 +1,9 @@
1−import type { Source } from "@executor/react";
1+import type {
2+ Loadable,
3+ Source,
4+ SourceInspection,
5+ SourceInspectionToolDetail,
6+} from "@executor/react";
27 import {
38 Result,
49 defineExecutorPluginHttpApiClient,
@@ -6,17 +11,38 @@ import { · 1 unmodified line
611 useAtomSet,
712 useExecutorMutation,
813 useLocalInstallation,
14+ usePrefetchToolDetail,
15+ useSourceDiscovery,
16+ useSourceInspection,
17+ useSourceToolDetail,
918 } from "@executor/react";
19+import { EmptyState, LoadableBlock } from "../../../apps/web/src/components/loadable";
20+import { DocumentPanel } from "../../../apps/web/src/components/document-panel";
21+import { Markdown } from "../../../apps/web/src/components/markdown";
22+import {
23+ IconCheck,
24+ IconChevron,
25+ IconClose,
26+ IconCopy,
27+ IconFolder,
28+ IconPencil,
29+ IconSearch,
30+ IconTool,
31+} from "../../../apps/web/src/components/icons";
32+import { Badge, MethodBadge } from "../../../apps/web/src/components/ui/badge";
33+import { cn } from "../../../apps/web/src/lib/utils";
1034 import {
1135 openApiHttpApiExtension,
1236 } from "@executor/plugin-openapi-http";
1337 import type {
1438 OpenApiConnectInput,
1539 OpenApiPreviewRequest,
40+ OpenApiPreviewSecurityScheme,
1641 OpenApiPreviewResponse,
42+ OpenApiSourceConfigPayload,
1743 } from "@executor/plugin-openapi-shared";
1844 import { useNavigate } from "@tanstack/react-router";
19−import { startTransition, useState, type ReactNode } from "react";
45+import { startTransition, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
2046
2147 type FrontendSourceTypeDefinition = {
2248 kind: string;
@@ -38,6 +64,12 @@ type FrontendPluginRegisterApi = { · 15 unmodified lines
3864 };
3965 };
4066
67+type RouteToolSearch = {
68+ tab?: "model" | "discover";
69+ tool?: string;
70+ query?: string;
71+};
72+
4173 const defaultOpenApiInput = (): OpenApiConnectInput => ({
4274 name: "My OpenAPI Source",
4375 specUrl: "https://example.com/openapi.json",
@@ -68,19 +100,108 @@ const Section = (props: { · 24 unmodified lines
68100 </section>
69101 );
70102
71−function OpenApiAddSourcePage(props: {
72− initialValue: OpenApiConnectInput;
73−}) {
74− const navigate = useNavigate();
103+type ToolTreeNode = {
104+ segment: string;
105+ tool?: SourceInspectionToolDetail["summary"] | SourceInspection["tools"][number];
106+ children: Map<string, ToolTreeNode>;
107+};
108+
109+const buildToolTree = (tools: SourceInspection["tools"]): ToolTreeNode => {
110+ const root: ToolTreeNode = {
111+ segment: "",
112+ children: new Map(),
113+ };
114+
115+ for (const tool of tools) {
116+ const parts = tool.path.split(".");
117+ let node = root;
118+ for (const part of parts) {
119+ const existing = node.children.get(part);
120+ if (existing) {
121+ node = existing;
122+ continue;
123+ }
124+
125+ const next: ToolTreeNode = {
126+ segment: part,
127+ children: new Map(),
128+ };
129+ node.children.set(part, next);
130+ node = next;
131+ }
132+ node.tool = tool;
133+ }
134+
135+ return root;
136+};
137+
138+const countToolLeaves = (node: ToolTreeNode): number => {
139+ let count = node.tool ? 1 : 0;
140+ for (const child of node.children.values()) {
141+ count += countToolLeaves(child);
142+ }
143+ return count;
144+};
145+
146+const isPreviewableSpecUrl = (value: string): boolean => {
147+ const trimmed = value.trim();
148+ if (!/^https?:\/\//i.test(trimmed)) {
149+ return false;
150+ }
151+
152+ try {
153+ const url = new URL(trimmed);
154+ return url.hostname !== "example.com";
155+ } catch {
156+ return false;
157+ }
158+};
159+
160+const previewSecuritySchemeLabel = (scheme: OpenApiPreviewSecurityScheme): string => {
161+ if (scheme.kind === "apiKey") {
162+ return scheme.placement ? `API key in ${scheme.placement}` : "API key";
163+ }
164+
165+ if (scheme.kind === "http") {
166+ return scheme.scheme ? `HTTP ${scheme.scheme}` : "HTTP auth";
167+ }
168+
169+ if (scheme.kind === "oauth2") {
170+ return "OAuth 2.0";
171+ }
172+
173+ if (scheme.kind === "openIdConnect") {
174+ return "OpenID Connect";
175+ }
176+
177+ return "Custom auth";
178+};
179+
180+const inputFromConfig = (
181+ config: OpenApiSourceConfigPayload,
182+): OpenApiConnectInput => ({
183+ name: config.name,
184+ specUrl: config.specUrl,
185+ baseUrl: config.baseUrl,
186+ auth: config.auth,
187+});
188+
189+const useWorkspaceId = (): Source["scopeId"] => {
75190 const installation = useLocalInstallation();
76− const [name, setName] = useState(props.initialValue.name);
77− const [specUrl, setSpecUrl] = useState(props.initialValue.specUrl);
78− const [baseUrl, setBaseUrl] = useState(props.initialValue.baseUrl ?? "");
79− const [error, setError] = useState<string | null>(null);
80− const [preview, setPreview] = useState<OpenApiPreviewResponse | null>(null);
81− const [nameEdited, setNameEdited] = useState(false);
82− const [baseUrlEdited, setBaseUrlEdited] = useState(false);
83− const openApiHttpClient = getOpenApiHttpClient();
191+ if (installation.status === "ready") {
192+ return installation.data.scopeId;
193+ }
194+
195+ if (installation.status === "error") {
196+ throw installation.error;
197+ }
198+
199+ throw new Error("Workspace is still loading.");
200+};
201+
202+const useAvailableSecrets = (
203+ openApiHttpClient: ReturnType<typeof getOpenApiHttpClient>,
204+) => {
84205 const secretsResult = useAtomValue(
85206 openApiHttpClient.query("local", "listSecrets", {
86207 reactivityKeys: {
@@ -89,9 +210,27 @@ function OpenApiAddSourcePage(props: { · 2 unmodified lines
89210 timeToLive: "1 minute",
90211 }),
91212 );
92− const availableSecrets = Result.isSuccess(secretsResult)
93− ? secretsResult.value
94− : [];
213+
214+ return Result.isSuccess(secretsResult) ? secretsResult.value : [];
215+};
216+
217+function OpenApiSourceForm(props: {
218+ mode: "create" | "edit";
219+ initialValue: OpenApiConnectInput;
220+ submitLabel: string;
221+ busyLabel: string;
222+ onSubmit: (input: OpenApiConnectInput) => Promise<void>;
223+}) {
224+ const openApiHttpClient = getOpenApiHttpClient();
225+ const availableSecrets = useAvailableSecrets(openApiHttpClient);
226+ const previewDocument = useAtomSet(
227+ openApiHttpClient.mutation("openapi", "previewDocument"),
228+ { mode: "promise" },
229+ );
230+ const workspaceId = useWorkspaceId();
231+ const [name, setName] = useState(props.initialValue.name);
232+ const [specUrl, setSpecUrl] = useState(props.initialValue.specUrl);
233+ const [baseUrl, setBaseUrl] = useState(props.initialValue.baseUrl ?? "");
95234 const [authKind, setAuthKind] = useState<OpenApiConnectInput["auth"]["kind"]>(
96235 props.initialValue.auth.kind,
97236 );
@@ -100,64 +239,36 @@ function OpenApiAddSourcePage(props: { · 2 unmodified lines
100239 ? props.initialValue.auth.tokenSecretRef
101240 : "",
102241 );
103− const previewDocument = useAtomSet(
104− openApiHttpClient.mutation("openapi", "previewDocument"),
105− { mode: "promise" },
106− );
107− const createSource = useAtomSet(
108− openApiHttpClient.mutation("openapi", "createSource"),
109− { mode: "promise" },
110− );
111−
112− const requireWorkspaceId = (): string => {
113− if (installation.status === "ready") {
114− return installation.data.scopeId;
115− }
116−
117− if (installation.status === "error") {
118− throw installation.error;
119− }
120−
121− throw new Error("Workspace is still loading.");
122− };
123−
242+ const [error, setError] = useState<string | null>(null);
243+ const [preview, setPreview] = useState<OpenApiPreviewResponse | null>(null);
244+ const [nameEdited, setNameEdited] = useState(false);
245+ const [baseUrlEdited, setBaseUrlEdited] = useState(false);
246+ const [lastPreviewedSpecUrl, setLastPreviewedSpecUrl] = useState<string | null>(null);
124247 const previewMutation = useExecutorMutation<
125248 OpenApiPreviewRequest,
126249 OpenApiPreviewResponse
127250 >(async (payload) =>
128251 previewDocument({
129− path: {
130− workspaceId: requireWorkspaceId() as Source["scopeId"],
131− },
132− payload,
133− })
134− );
135−
136− const createMutation = useExecutorMutation<
137− OpenApiConnectInput,
138− Source
139− >(async (payload) =>
140− createSource({
141− path: {
142− workspaceId: requireWorkspaceId() as Source["scopeId"],
143− },
252+ path: { workspaceId },
144253 payload,
145− reactivityKeys: {
146− sources: [requireWorkspaceId()],
147− },
148254 })
149255 );
256+ const submitMutation = useExecutorMutation<OpenApiConnectInput, void>(props.onSubmit);
150257
151− const handlePreview = async () => {
152− setError(null);
258+ const runPreview = async (input: {
259+ mode: "auto" | "manual";
260+ }) => {
153261 const trimmedSpecUrl = specUrl.trim();
154262 if (!trimmedSpecUrl) {
155− setError("Spec URL is required.");
263+ if (input.mode === "manual") {
264+ setError("Spec URL is required.");
265+ }
266+ setPreview(null);
267+ setLastPreviewedSpecUrl(null);
156268 return;
157269 }
158270
159− if (authKind === "bearer" && tokenSecretRef.trim().length === 0) {
160− setError("Select a secret for bearer auth.");
271+ if (!isPreviewableSpecUrl(trimmedSpecUrl)) {
161272 return;
162273 }
163274
@@ -168,24 +279,48 @@ function OpenApiAddSourcePage(props: { · 4 unmodified lines
168279 setPreview({
169280 ...result,
170281 warnings: [...result.warnings],
282+ securitySchemes: [...result.securitySchemes],
171283 });
284+ setLastPreviewedSpecUrl(trimmedSpecUrl);
285+ if (error) {
286+ setError(null);
287+ }
172288
173289 if (!nameEdited && result.title) {
174290 setName(result.title);
175291 }
176−
177292 if (!baseUrlEdited && result.baseUrl) {
178293 setBaseUrl(result.baseUrl);
179294 }
180295 } catch (cause) {
181− setError(cause instanceof Error ? cause.message : "Failed previewing document.");
296+ if (input.mode === "manual") {
297+ setError(cause instanceof Error ? cause.message : "Failed previewing document.");
298+ }
182299 setPreview(null);
183300 }
184301 };
185302
303+ useEffect(() => {
304+ const trimmedSpecUrl = specUrl.trim();
305+ if (!isPreviewableSpecUrl(trimmedSpecUrl)) {
306+ return;
307+ }
308+
309+ if (trimmedSpecUrl === lastPreviewedSpecUrl) {
310+ return;
311+ }
312+
313+ const timeout = window.setTimeout(() => {
314+ void runPreview({ mode: "auto" });
315+ }, 450);
316+
317+ return () => {
318+ window.clearTimeout(timeout);
319+ };
320+ }, [specUrl, lastPreviewedSpecUrl]);
321+
186322 const handleSubmit = async () => {
187323 setError(null);
188−
189324 const trimmedName = name.trim();
190325 const trimmedSpecUrl = specUrl.trim();
191326 const trimmedBaseUrl = baseUrl.trim();
@@ -194,14 +329,17 @@ function OpenApiAddSourcePage(props: { · 2 unmodified lines
194329 setError("Name is required.");
195330 return;
196331 }
197−
198332 if (!trimmedSpecUrl) {
199333 setError("Spec URL is required.");
200334 return;
201335 }
336+ if (authKind === "bearer" && tokenSecretRef.trim().length === 0) {
337+ setError("Select a secret for bearer auth.");
338+ return;
339+ }
202340
203341 try {
204− const source = await createMutation.mutateAsync({
342+ await submitMutation.mutateAsync({
205343 name: trimmedName,
206344 specUrl: trimmedSpecUrl,
207345 baseUrl: trimmedBaseUrl || null,
@@ -209,26 +347,14 @@ function OpenApiAddSourcePage(props: { · 1 unmodified line
209347 authKind === "bearer"
210348 ? {
211349 kind: "bearer",
212− tokenSecretRef,
350+ tokenSecretRef: tokenSecretRef.trim(),
213351 }
214352 : {
215353 kind: "none",
216354 },
217355 });
218−
219− startTransition(() => {
220− void navigate({
221− to: "/sources/$sourceId",
222− params: {
223− sourceId: source.id,
224− },
225− search: {
226− tab: "model",
227− },
228− });
229− });
230356 } catch (cause) {
231− setError(cause instanceof Error ? cause.message : "Failed creating source.");
357+ setError(cause instanceof Error ? cause.message : "Failed saving source.");
232358 }
233359 };
234360
@@ -258,6 +384,9 @@ function OpenApiAddSourcePage(props: { · 23 unmodified lines
258384 <input
259385 value={specUrl}
260386 onChange={(event) => setSpecUrl(event.target.value)}
387+ onBlur={() => {
388+ void runPreview({ mode: "manual" });
389+ }}
261390 placeholder="https://example.com/openapi.json"
262391 className="h-10 w-full rounded-lg border border-input bg-background px-3 font-mono text-[12px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25"
263392 />
@@ -318,13 +447,18 @@ function OpenApiAddSourcePage(props: { · 54 unmodified lines
318447 <button
319448 type="button"
320449 onClick={() => {
321− void handlePreview();
450+ void runPreview({ mode: "manual" });
322451 }}
323− disabled={previewMutation.status === "pending" || createMutation.status === "pending"}
452+ disabled={previewMutation.status === "pending" || submitMutation.status === "pending"}
324453 className="inline-flex h-7 items-center justify-center rounded-md border border-input bg-transparent px-2.5 text-xs font-medium text-foreground disabled:pointer-events-none disabled:opacity-50"
325454 >
326455 {previewMutation.status === "pending" ? "Previewing..." : "Preview OpenAPI Document"}
327456 </button>
457+ {previewMutation.status === "pending" && (
458+ <div className="text-xs text-muted-foreground">
459+ Inferring from spec...
460+ </div>
461+ )}
328462 {preview && (
329463 <div className="text-xs text-muted-foreground">
330464 {preview.operationCount} operations
@@ -347,6 +481,20 @@ function OpenApiAddSourcePage(props: { · 16 unmodified lines
347481 <span className="font-medium text-foreground">Base URL:</span>{" "}
348482 <span className="text-muted-foreground">{preview.baseUrl ?? "Not declared"}</span>
349483 </div>
484+ <div>
485+ <span className="font-medium text-foreground">Namespace:</span>{" "}
486+ <span className="text-muted-foreground">{preview.namespace ?? "Not inferred"}</span>
487+ </div>
488+ <div>
489+ <span className="font-medium text-foreground">Auth:</span>{" "}
490+ <span className="text-muted-foreground">
491+ {preview.securitySchemes.length > 0
492+ ? preview.securitySchemes
493+ .map((scheme) => `${scheme.name} (${previewSecuritySchemeLabel(scheme)})`)
494+ .join(", ")
495+ : "No declared auth schemes"}
496+ </span>
497+ </div>
350498 </div>
351499 {preview.warnings.length > 0 && (
352500 <div className="mt-3 rounded-md border border-amber-300/40 bg-amber-100/20 px-3 py-2 text-xs text-amber-800">
@@ -357,11 +505,7 @@ function OpenApiAddSourcePage(props: { · 4 unmodified lines
357505 )}
358506 </Section>
359507
360− <Section title="Submit">
361− <p className="text-sm text-muted-foreground">
362− This plugin can use its own HTTP endpoints and the core API from one merged client. That
363− is how the bearer-secret selector is loaded without a separate app-owned client path.
364− </p>
508+ <Section title={props.mode === "create" ? "Submit" : "Save"}>
365509 {error && (
366510 <div className="mb-3 rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-2.5 text-[13px] text-destructive">
367511 {error}
@@ -373,13 +517,15 @@ function OpenApiAddSourcePage(props: { · 5 unmodified lines
373517 onClick={() => {
374518 void handleSubmit();
375519 }}
376− disabled={createMutation.status === "pending" || installation.status === "loading"}
520+ disabled={submitMutation.status === "pending"}
377521 className="inline-flex h-7 items-center justify-center rounded-md bg-primary px-2.5 text-xs font-medium text-primary-foreground disabled:pointer-events-none disabled:opacity-50"
378522 >
379− {createMutation.status === "pending" ? "Creating..." : "Create Source"}
523+ {submitMutation.status === "pending" ? props.busyLabel : props.submitLabel}
380524 </button>
381525 <div className="text-xs text-muted-foreground">
382− Creates a real <code>kind: &quot;openapi&quot;</code> source.
526+ {props.mode === "create"
527+ ? "Creates a real source and immediately imports its tools."
528+ : "Updates plugin-owned config and refreshes the imported model."}
383529 </div>
384530 </div>
385531 </Section>
@@ -387,55 +533,900 @@ function OpenApiAddSourcePage(props: { · 1 unmodified line
387533 );
388534 }
389535
536+function OpenApiAddSourcePage(props: {
537+ initialValue: OpenApiConnectInput;
538+}) {
539+ const navigate = useNavigate();
540+ const openApiHttpClient = getOpenApiHttpClient();
541+ const createSource = useAtomSet(
542+ openApiHttpClient.mutation("openapi", "createSource"),
543+ { mode: "promise" },
544+ );
545+ const workspaceId = useWorkspaceId();
546+
547+ return (
548+ <OpenApiSourceForm
549+ mode="create"
550+ initialValue={props.initialValue}
551+ submitLabel="Create Source"
552+ busyLabel="Creating..."
553+ onSubmit={async (payload) => {
554+ const source = await createSource({
555+ path: { workspaceId },
556+ payload,
557+ reactivityKeys: {
558+ sources: [workspaceId],
559+ },
560+ });
561+
562+ startTransition(() => {
563+ void navigate({
564+ to: "/sources/$sourceId",
565+ params: {
566+ sourceId: source.id,
567+ },
568+ search: {
569+ tab: "model",
570+ },
571+ });
572+ });
573+ }}
574+ />
575+ );
576+}
577+
390578 function OpenApiEditSourcePage(props: {
391579 source: Source;
392580 }) {
581+ const navigate = useNavigate();
582+ const openApiHttpClient = getOpenApiHttpClient();
583+ const workspaceId = useWorkspaceId();
584+ const configResult = useAtomValue(
585+ openApiHttpClient.query("openapi", "getSourceConfig", {
586+ path: {
587+ workspaceId,
588+ sourceId: props.source.id,
589+ },
590+ reactivityKeys: {
591+ source: [workspaceId, props.source.id],
592+ },
593+ timeToLive: "30 seconds",
594+ }),
595+ );
596+ const updateSource = useAtomSet(
597+ openApiHttpClient.mutation("openapi", "updateSource"),
598+ { mode: "promise" },
599+ );
600+
601+ if (!Result.isSuccess(configResult)) {
602+ if (Result.isFailure(configResult)) {
603+ return (
604+ <Section title="OpenAPI Plugin Editor">
605+ <div className="rounded-lg border border-destructive/30 bg-destructive/8 p-4 text-sm text-destructive">
606+ Failed loading plugin config.
607+ </div>
608+ </Section>
609+ );
610+ }
611+
612+ return (
613+ <Section title="OpenAPI Plugin Editor">
614+ <div className="text-sm text-muted-foreground">Loading plugin config...</div>
615+ </Section>
616+ );
617+ }
618+
393619 return (
394− <Section title="OpenAPI Plugin Editor">
395− <p className="text-sm text-muted-foreground">
396− Plugin-specific configuration is stored behind the OpenAPI plugin boundary, not on the
397− shared source row.
398− </p>
399− <pre className="mt-3 overflow-x-auto rounded-md bg-muted/60 p-3 text-xs">
400− {JSON.stringify({
401− id: props.source.id,
402− kind: props.source.kind,
403− }, null, 2)}
404− </pre>
405− </Section>
620+ <OpenApiSourceForm
621+ mode="edit"
622+ initialValue={inputFromConfig(configResult.value)}
623+ submitLabel="Save Changes"
624+ busyLabel="Saving..."
625+ onSubmit={async (config) => {
626+ const source = await updateSource({
627+ path: {
628+ workspaceId,
629+ sourceId: props.source.id,
630+ },
631+ payload: config,
632+ reactivityKeys: {
633+ sources: [workspaceId],
634+ source: [workspaceId, props.source.id],
635+ sourceInspection: [workspaceId, props.source.id],
636+ sourceInspectionTool: [workspaceId, props.source.id],
637+ sourceDiscovery: [workspaceId, props.source.id],
638+ },
639+ });
640+
641+ startTransition(() => {
642+ void navigate({
643+ to: "/sources/$sourceId",
644+ params: {
645+ sourceId: source.id,
646+ },
647+ search: {
648+ tab: "model",
649+ },
650+ });
651+ });
652+ }}
653+ />
406654 );
407655 }
408656
409657 function OpenApiSourceDetailPage(props: {
410658 source: Source;
659+ route: {
660+ search?: unknown;
661+ navigate?: unknown;
662+ };
411663 }) {
664+ const routerNavigate = useNavigate();
665+ const openApiHttpClient = getOpenApiHttpClient();
666+ const workspaceId = useWorkspaceId();
667+ const removeSource = useAtomSet(
668+ openApiHttpClient.mutation("openapi", "removeSource"),
669+ { mode: "promise" },
670+ );
671+ const removeMutation = useExecutorMutation<Source["id"], { removed: boolean }>(async (sourceId) =>
672+ removeSource({
673+ path: {
674+ workspaceId,
675+ sourceId,
676+ },
677+ reactivityKeys: {
678+ sources: [workspaceId],
679+ source: [workspaceId, sourceId],
680+ sourceInspection: [workspaceId, sourceId],
681+ sourceInspectionTool: [workspaceId, sourceId],
682+ sourceDiscovery: [workspaceId, sourceId],
683+ },
684+ })
685+ );
686+ const inspection = useSourceInspection(props.source.id);
687+ const search = (props.route.search ?? {}) as RouteToolSearch;
688+ const navigate =
689+ props.route.navigate as
690+ | ((input: {
691+ search: {
692+ tab: "model" | "discover";
693+ tool?: string;
694+ query?: string;
695+ };
696+ }) => void | Promise<void>)
697+ | undefined;
698+ const tab = search.tab === "discover" ? "discover" : "model";
699+ const query = search.query ?? "";
700+ const selectedToolPath =
701+ search.tool ?? (inspection.status === "ready" ? inspection.data.tools[0]?.path ?? null : null);
702+ const discovery = useSourceDiscovery({
703+ sourceId: props.source.id,
704+ query,
705+ limit: 20,
706+ });
707+ const toolDetail = useSourceToolDetail(props.source.id, selectedToolPath);
708+
709+ const setRouteSearch = (next: {
710+ tab?: "model" | "discover";
711+ tool?: string;
712+ query?: string;
713+ }) => {
714+ if (!navigate) {
715+ return;
716+ }
717+
718+ void navigate({
719+ search: {
720+ tab: next.tab ?? tab,
721+ ...(next.tool !== undefined
722+ ? { tool: next.tool || undefined }
723+ : { tool: search.tool }),
724+ ...(next.query !== undefined
725+ ? { query: next.query || undefined }
726+ : { query }),
727+ },
728+ });
729+ };
730+
731+ useEffect(() => {
732+ if (tab !== "model" || selectedToolPath || inspection.status !== "ready") {
733+ return;
734+ }
735+
736+ const firstTool = inspection.data.tools[0]?.path;
737+ if (!firstTool) {
738+ return;
739+ }
740+
741+ setRouteSearch({
742+ tab: "model",
743+ tool: firstTool,
744+ });
745+ }, [inspection.status, selectedToolPath, tab]);
746+
412747 return (
413− <div className="space-y-4">
414− <Section title="Source Detail">
415− <p className="text-sm text-muted-foreground">
416− OpenAPI-specific configuration and auth live in plugin storage. The shared source record
417− only carries generic routing and status metadata.
418− </p>
419− <pre className="mt-3 overflow-x-auto rounded-md bg-muted/60 p-3 text-xs">
420− {JSON.stringify({
421− id: props.source.id,
422− name: props.source.name,
423− kind: props.source.kind,
424− status: props.source.status,
425− }, null, 2)}
426− </pre>
427− </Section>
748+ <LoadableBlock loadable={inspection} loading="Loading source...">
749+ {(loadedInspection) => {
750+ const selectedTool =
751+ loadedInspection.tools.find((tool) => tool.path === selectedToolPath)
752+ ?? loadedInspection.tools[0]
753+ ?? null;
428754
429− <Section title="Plugin Boundary">
430− <p className="text-sm text-muted-foreground">
431− The shell only knows there is a registered source plugin named <code>openapi</code>.
432− Everything specific stays inside this plugin.
433− </p>
434− </Section>
755+ return (
756+ <div className="flex h-full flex-col overflow-hidden">
757+ <div className="flex h-12 shrink-0 items-center justify-between border-b border-border bg-background/95 px-4 backdrop-blur-sm">
758+ <div className="flex min-w-0 items-center gap-3">
759+ <h2 className="truncate text-sm font-semibold text-foreground">
760+ {loadedInspection.source.name}
761+ </h2>
762+ <Badge variant="outline">{loadedInspection.source.kind}</Badge>
763+ <span className="hidden text-[11px] tabular-nums text-muted-foreground/50 sm:block">
764+ {loadedInspection.toolCount} {loadedInspection.toolCount === 1 ? "tool" : "tools"}
765+ </span>
766+ </div>
767+ <div className="flex items-center gap-2">
768+ <div className="flex items-center gap-0.5 rounded-lg bg-muted/50 p-0.5">
769+ {(["model", "discover"] as const).map((tabId) => (
770+ <button
771+ key={tabId}
772+ type="button"
773+ onClick={() => setRouteSearch({ tab: tabId })}
774+ className={cn(
775+ "rounded-md px-3 py-1 text-[12px] font-medium transition-colors",
776+ tabId === tab
777+ ? "bg-background text-foreground shadow-sm"
778+ : "text-muted-foreground hover:text-foreground",
779+ )}
780+ >
781+ {tabId === "model" ? "Tools" : "Search"}
782+ </button>
783+ ))}
784+ </div>
785+ <button
786+ type="button"
787+ onClick={() =>
788+ void routerNavigate({
789+ to: "/sources/$sourceId/edit",
790+ params: { sourceId: props.source.id },
791+ })}
792+ className="inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-2.5 py-1 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
793+ >
794+ <IconPencil className="size-3" />
795+ Edit
796+ </button>
797+ <button
798+ type="button"
799+ onClick={() => {
800+ const confirmed = window.confirm(`Delete OpenAPI source "${props.source.name}"?`);
801+ if (!confirmed) return;
802+
803+ void removeMutation.mutateAsync(props.source.id).then(() => {
804+ startTransition(() => {
805+ void routerNavigate({ to: "/" });
806+ });
807+ });
808+ }}
809+ disabled={removeMutation.status === "pending"}
810+ className="inline-flex items-center rounded-md border border-destructive/25 bg-destructive/5 px-2.5 py-1 text-[12px] font-medium text-destructive transition-colors hover:bg-destructive/10 disabled:pointer-events-none disabled:opacity-50"
811+ >
812+ {removeMutation.status === "pending" ? "Deleting..." : "Delete"}
813+ </button>
814+ </div>
815+ </div>
816+
817+ <div className="flex flex-1 min-h-0 overflow-hidden">
818+ {tab === "model" ? (
819+ <ModelView
820+ bundle={loadedInspection}
821+ detail={toolDetail}
822+ selectedToolPath={selectedTool?.path ?? null}
823+ onSelectTool={(toolPath) =>
824+ setRouteSearch({
825+ tab: "model",
826+ tool: toolPath,
827+ })}
828+ sourceId={props.source.id}
829+ />
830+ ) : (
831+ <DiscoveryView
832+ bundle={loadedInspection}
833+ initialQuery={query}
834+ discovery={discovery}
835+ onSubmitQuery={(nextQuery) =>
836+ setRouteSearch({
837+ tab: "discover",
838+ query: nextQuery,
839+ })}
840+ onOpenTool={(toolPath) =>
841+ setRouteSearch({
842+ tab: "model",
843+ tool: toolPath,
844+ query,
845+ })}
846+ />
847+ )}
848+ </div>
849+ </div>
850+ );
851+ }}
852+ </LoadableBlock>
853+ );
854+}
855+
856+function ModelView(props: {
857+ bundle: SourceInspection;
858+ detail: Loadable<SourceInspectionToolDetail | null>;
859+ selectedToolPath: string | null;
860+ onSelectTool: (toolPath: string) => void;
861+ sourceId: string;
862+}) {
863+ const [search, setSearch] = useState("");
864+ const searchRef = useRef<HTMLInputElement>(null);
865+ const terms = search.trim().toLowerCase().split(/\s+/).filter(Boolean);
866+
867+ const filteredTools = props.bundle.tools.filter((tool) => {
868+ if (terms.length === 0) return true;
869+ const corpus = [
870+ tool.path,
871+ tool.method ?? "",
872+ tool.inputTypePreview ?? "",
873+ tool.outputTypePreview ?? "",
874+ ]
875+ .join(" ")
876+ .toLowerCase();
877+ return terms.every((term) => corpus.includes(term));
878+ });
879+
880+ useEffect(() => {
881+ const handler = (event: KeyboardEvent) => {
882+ if (event.key === "/" && document.activeElement?.tagName !== "INPUT") {
883+ event.preventDefault();
884+ searchRef.current?.focus();
885+ }
886+ if (event.key === "Escape") {
887+ searchRef.current?.blur();
888+ if (search.length > 0) setSearch("");
889+ }
890+ };
891+ document.addEventListener("keydown", handler);
892+ return () => document.removeEventListener("keydown", handler);
893+ }, [search]);
894+
895+ return (
896+ <>
897+ <div className="flex w-72 shrink-0 flex-col border-r border-border bg-card/30 lg:w-80 xl:w-[22rem]">
898+ <div className="shrink-0 border-b border-border px-3 py-2">
899+ <div className="relative">
900+ <IconSearch className="absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground/40" />
901+ <input
902+ ref={searchRef}
903+ value={search}
904+ onChange={(event) => setSearch(event.target.value)}
905+ placeholder={`Filter ${props.bundle.toolCount} tools…`}
906+ className="h-8 w-full rounded-md border border-input bg-background pl-8 pr-8 text-[13px] outline-none transition-colors placeholder:text-muted-foreground/40 focus:border-ring focus:ring-1 focus:ring-ring/30"
907+ />
908+ {search.length > 0 ? (
909+ <button
910+ type="button"
911+ onClick={() => setSearch("")}
912+ className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-muted-foreground/40 hover:text-foreground"
913+ >
914+ <IconClose />
915+ </button>
916+ ) : (
917+ <kbd className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded border border-border bg-muted px-1 py-px text-[10px] text-muted-foreground/50">
918+ /
919+ </kbd>
920+ )}
921+ </div>
922+ </div>
923+
924+ <div className="flex-1 overflow-y-auto">
925+ {filteredTools.length === 0 ? (
926+ <div className="p-4 text-center text-[13px] text-muted-foreground/50">
927+ {terms.length > 0 ? "No tools match your filter" : "No tools available"}
928+ </div>
929+ ) : (
930+ <div className="p-1.5">
931+ <ToolTree
932+ tools={filteredTools}
933+ selectedToolPath={props.selectedToolPath}
934+ onSelectTool={props.onSelectTool}
935+ search={search}
936+ isFiltered={terms.length > 0}
937+ sourceId={props.sourceId}
938+ />
939+ </div>
940+ )}
941+ </div>
942+ </div>
943+
944+ <div className="flex min-w-0 flex-1 flex-col overflow-hidden">
945+ <LoadableBlock loadable={props.detail} loading="Loading tool...">
946+ {(detail) =>
947+ detail ? (
948+ <ToolDetailPanel detail={detail} />
949+ ) : (
950+ <EmptyState
951+ title={props.bundle.toolCount > 0 ? "Select a tool" : "No tools available"}
952+ description={props.bundle.toolCount > 0 ? "Choose from the list or press / to search" : undefined}
953+ />
954+ )
955+ }
956+ </LoadableBlock>
957+ </div>
958+ </>
959+ );
960+}
961+
962+function ToolTree(props: {
963+ tools: SourceInspection["tools"];
964+ selectedToolPath: string | null;
965+ onSelectTool: (path: string) => void;
966+ search: string;
967+ isFiltered: boolean;
968+ sourceId: string;
969+}) {
970+ const tree = useMemo(() => buildToolTree(props.tools), [props.tools]);
971+ const prefetch = usePrefetchToolDetail();
972+ const entries = [...tree.children.values()].sort((a, b) => a.segment.localeCompare(b.segment));
973+
974+ return (
975+ <div className="flex flex-col gap-px">
976+ {entries.map((node) => (
977+ <ToolTreeNodeView
978+ key={node.segment}
979+ node={node}
980+ depth={0}
981+ selectedToolPath={props.selectedToolPath}
982+ onSelectTool={props.onSelectTool}
983+ search={props.search}
984+ defaultOpen={props.isFiltered}
985+ sourceId={props.sourceId}
986+ prefetch={prefetch}
987+ />
988+ ))}
989+ </div>
990+ );
991+}
992+
993+function ToolTreeNodeView(props: {
994+ node: ToolTreeNode;
995+ depth: number;
996+ selectedToolPath: string | null;
997+ onSelectTool: (path: string) => void;
998+ search: string;
999+ defaultOpen: boolean;
1000+ sourceId: string;
1001+ prefetch: ReturnType<typeof usePrefetchToolDetail>;
1002+}) {
1003+ const { node, depth, selectedToolPath, onSelectTool, search, defaultOpen, sourceId, prefetch } = props;
1004+ const hasChildren = node.children.size > 0;
1005+ const isLeaf = !!node.tool && !hasChildren;
1006+
1007+ const hasSelectedDescendant = useMemo(() => {
1008+ if (!selectedToolPath) return false;
1009+ function check(candidate: ToolTreeNode): boolean {
1010+ if (candidate.tool?.path === selectedToolPath) return true;
1011+ for (const child of candidate.children.values()) {
1012+ if (check(child)) return true;
1013+ }
1014+ return false;
1015+ }
1016+ return check(node);
1017+ }, [node, selectedToolPath]);
1018+
1019+ const [open, setOpen] = useState(defaultOpen || hasSelectedDescendant);
1020+
1021+ useEffect(() => {
1022+ if (defaultOpen || hasSelectedDescendant) setOpen(true);
1023+ }, [defaultOpen, hasSelectedDescendant]);
1024+
1025+ if (isLeaf) {
1026+ return (
1027+ <ToolListItem
1028+ tool={node.tool as SourceInspection["tools"][number]}
1029+ active={node.tool?.path === selectedToolPath}
1030+ onSelect={() => onSelectTool(node.tool!.path)}
1031+ search={search}
1032+ depth={depth}
1033+ sourceId={sourceId}
1034+ prefetch={prefetch}
1035+ />
1036+ );
1037+ }
1038+
1039+ const paddingLeft = 8 + depth * 16;
1040+ const sortedChildren = [...node.children.values()].sort((a, b) => a.segment.localeCompare(b.segment));
1041+ const leafCount = countToolLeaves(node);
1042+
1043+ return (
1044+ <div>
1045+ {node.tool ? (
1046+ <div className="flex items-center">
1047+ <button
1048+ type="button"
1049+ onClick={() => setOpen((value) => !value)}
1050+ className="shrink-0 rounded p-0.5 text-muted-foreground/30 hover:text-muted-foreground"
1051+ style={{ marginLeft: paddingLeft }}
1052+ >
1053+ <IconChevron className={cn("size-2.5 transition-transform duration-150", open && "rotate-90")} />
1054+ </button>
1055+ <ToolListItem
1056+ tool={node.tool as SourceInspection["tools"][number]}
1057+ active={node.tool?.path === selectedToolPath}
1058+ onSelect={() => onSelectTool(node.tool!.path)}
1059+ search={search}
1060+ depth={-1}
1061+ className="flex-1 pl-1"
1062+ sourceId={sourceId}
1063+ prefetch={prefetch}
1064+ />
1065+ </div>
1066+ ) : (
1067+ <button
1068+ type="button"
1069+ onClick={() => setOpen((value) => !value)}
1070+ className={cn(
1071+ "group flex w-full items-center gap-1.5 rounded-md py-1 pr-2.5 text-[12px] transition-colors hover:bg-accent/40",
1072+ open ? "text-foreground/80" : "text-muted-foreground/60",
1073+ )}
1074+ style={{ paddingLeft }}
1075+ >
1076+ <IconChevron
1077+ className={cn(
1078+ "size-2.5 shrink-0 text-muted-foreground/30 transition-transform duration-150",
1079+ open && "rotate-90",
1080+ )}
1081+ />
1082+ <IconFolder className={cn("size-3 shrink-0", open ? "text-primary/60" : "text-muted-foreground/30")} />
1083+ <span className="flex-1 truncate text-left font-mono">
1084+ {highlightMatch(node.segment, search)}
1085+ </span>
1086+ <span className="shrink-0 tabular-nums text-[10px] text-muted-foreground/25">{leafCount}</span>
1087+ </button>
1088+ )}
1089+
1090+ {open && hasChildren && (
1091+ <div className="relative flex flex-col gap-px">
1092+ <span className="absolute bottom-1 top-0 w-px bg-border/40" style={{ left: paddingLeft + 5 }} aria-hidden />
1093+ {sortedChildren.map((child) => (
1094+ <ToolTreeNodeView
1095+ key={child.segment}
1096+ node={child}
1097+ depth={depth + 1}
1098+ selectedToolPath={selectedToolPath}
1099+ onSelectTool={onSelectTool}
1100+ search={search}
1101+ defaultOpen={defaultOpen}
1102+ sourceId={sourceId}
1103+ prefetch={prefetch}
1104+ />
1105+ ))}
1106+ </div>
1107+ )}
1108+ </div>
1109+ );
1110+}
1111+
1112+function ToolListItem(props: {
1113+ tool: SourceInspection["tools"][number];
1114+ active: boolean;
1115+ onSelect: () => void;
1116+ search: string;
1117+ depth: number;
1118+ className?: string;
1119+ sourceId: string;
1120+ prefetch: ReturnType<typeof usePrefetchToolDetail>;
1121+}) {
1122+ const ref = useRef<HTMLButtonElement>(null);
1123+ const paddingLeft = props.depth >= 0 ? 8 + props.depth * 16 + 8 : undefined;
1124+
1125+ useEffect(() => {
1126+ if (props.active && ref.current) {
1127+ ref.current.scrollIntoView({ block: "nearest" });
1128+ }
1129+ }, [props.active]);
1130+
1131+ const label = props.depth >= 0
1132+ ? props.tool.path.split(".").pop() ?? props.tool.path
1133+ : props.tool.path;
1134+
1135+ return (
1136+ <button
1137+ ref={ref}
1138+ type="button"
1139+ onMouseEnter={() => {
1140+ props.prefetch(props.sourceId, props.tool.path);
1141+ }}
1142+ onClick={props.onSelect}
1143+ className={cn(
1144+ "group flex w-full items-center gap-2 rounded-md py-1.5 pr-2.5 text-left transition-colors",
1145+ props.active
1146+ ? "border-l-2 border-l-primary bg-primary/10 text-foreground"
1147+ : "text-foreground/70 hover:bg-accent/50 hover:text-foreground",
1148+ props.className,
1149+ )}
1150+ style={paddingLeft != null ? { paddingLeft } : undefined}
1151+ >
1152+ <IconTool className="size-3 shrink-0 text-muted-foreground/40" />
1153+ <span className="flex-1 truncate font-mono text-[12px]">
1154+ {highlightMatch(label, props.search)}
1155+ </span>
1156+ {props.tool.method && <MethodBadge method={props.tool.method} />}
1157+ </button>
1158+ );
1159+}
1160+
1161+function ToolDetailPanel(props: {
1162+ detail: SourceInspectionToolDetail;
1163+}) {
1164+ const { detail } = props;
1165+ const [copiedField, setCopiedField] = useState<string | null>(null);
1166+ const inputType = detail.contract.input.typeDeclaration
1167+ ?? detail.contract.input.typePreview
1168+ ?? null;
1169+ const outputType = detail.contract.output.typeDeclaration
1170+ ?? detail.contract.output.typePreview
1171+ ?? null;
1172+
1173+ return (
1174+ <div className="flex h-full flex-col overflow-hidden">
1175+ <div className="shrink-0 border-b border-border bg-background/95 backdrop-blur-sm">
1176+ <div className="flex items-start gap-3 px-5 py-3.5">
1177+ <div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
1178+ <IconTool className="size-4" />
1179+ </div>
1180+ <div className="min-w-0 flex-1">
1181+ <div className="flex items-center gap-2">
1182+ <h3 className="truncate text-sm font-semibold text-foreground">
1183+ {detail.summary.path}
1184+ </h3>
1185+ <CopyButton
1186+ text={detail.summary.path}
1187+ field="path"
1188+ copiedField={copiedField}
1189+ onCopy={async (text, field) => {
1190+ await navigator.clipboard.writeText(text);
1191+ setCopiedField(field);
1192+ window.setTimeout(() => setCopiedField(null), 1500);
1193+ }}
1194+ />
1195+ </div>
1196+ <div className="mt-1 flex flex-wrap items-center gap-1.5">
1197+ {detail.summary.method && <MethodBadge method={detail.summary.method} />}
1198+ {detail.summary.pathTemplate && (
1199+ <span className="font-mono text-[11px] text-muted-foreground/60">
1200+ {detail.summary.pathTemplate}
1201+ </span>
1202+ )}
1203+ </div>
1204+ </div>
1205+ </div>
1206+ </div>
1207+
1208+ <div className="flex-1 overflow-y-auto">
1209+ <div className="space-y-4 px-5 py-4">
1210+ {detail.summary.description && <Markdown>{detail.summary.description}</Markdown>}
1211+
1212+ <div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
1213+ <DocumentPanel title="Input" body={inputType} lang="typescript" empty="No input." />
1214+ <DocumentPanel title="Output" body={outputType} lang="typescript" empty="No output." />
1215+ </div>
1216+
1217+ <DocumentPanel title="Call Signature" body={detail.contract.callSignature} lang="typescript" empty="No call signature." />
1218+
1219+ <div className="grid grid-cols-1 gap-2 xl:grid-cols-2">
1220+ <DocumentPanel title="Input schema" body={detail.contract.input.schemaJson} empty="No input schema." compact />
1221+ <DocumentPanel title="Output schema" body={detail.contract.output.schemaJson} empty="No output schema." compact />
1222+ {detail.contract.input.exampleJson && (
1223+ <DocumentPanel title="Example request" body={detail.contract.input.exampleJson} empty="" compact />
1224+ )}
1225+ {detail.contract.output.exampleJson && (
1226+ <DocumentPanel title="Example response" body={detail.contract.output.exampleJson} empty="" compact />
1227+ )}
1228+ </div>
1229+
1230+ {detail.sections.map((section, index) => (
1231+ <section
1232+ key={`${section.title}-${String(index)}`}
1233+ className="overflow-hidden rounded-lg border border-border bg-card/60"
1234+ >
1235+ <div className="border-b border-border px-3 py-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/70">
1236+ {section.title}
1237+ </div>
1238+ {section.kind === "facts" ? (
1239+ <div className="grid gap-2 p-4">
1240+ {section.items.map((item) => (
1241+ <div key={`${section.title}-${item.label}`} className="text-sm">
1242+ <span className="text-muted-foreground">{item.label}:</span>{" "}
1243+ <span className={item.mono ? "font-mono text-xs text-foreground" : "text-foreground"}>
1244+ {item.value}
1245+ </span>
1246+ </div>
1247+ ))}
1248+ </div>
1249+ ) : section.kind === "markdown" ? (
1250+ <div className="p-4">
1251+ <Markdown>{section.body}</Markdown>
1252+ </div>
1253+ ) : (
1254+ <DocumentPanel title={section.title} body={section.body} lang={section.language} empty="" />
1255+ )}
1256+ </section>
1257+ ))}
1258+ </div>
1259+ </div>
4351260 </div>
4361261 );
4371262 }
4381263
1264+function DiscoveryView(props: {
1265+ bundle: SourceInspection;
1266+ discovery: Loadable<ReturnType<typeof useSourceDiscovery> extends Loadable<infer T> ? T : never>;
1267+ initialQuery: string;
1268+ onSubmitQuery: (query: string) => void;
1269+ onOpenTool: (toolPath: string) => void;
1270+}) {
1271+ const [draftQuery, setDraftQuery] = useState(props.initialQuery);
1272+
1273+ useEffect(() => {
1274+ setDraftQuery(props.initialQuery);
1275+ }, [props.initialQuery]);
1276+
1277+ return (
1278+ <div className="flex flex-1 flex-col overflow-hidden">
1279+ <div className="shrink-0 border-b border-border px-4 py-3">
1280+ <form
1281+ className="flex max-w-2xl items-center gap-2"
1282+ onSubmit={(event) => {
1283+ event.preventDefault();
1284+ props.onSubmitQuery(draftQuery.trim());
1285+ }}
1286+ >
1287+ <div className="relative flex-1">
1288+ <IconSearch className="absolute left-3 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground/40" />
1289+ <input
1290+ value={draftQuery}
1291+ onChange={(event) => setDraftQuery(event.target.value)}
1292+ placeholder="Search tools…"
1293+ className="h-9 w-full rounded-md border border-input bg-background pl-9 pr-3 text-sm outline-none transition-colors placeholder:text-muted-foreground/40 focus:border-ring focus:ring-1 focus:ring-ring/30"
1294+ />
1295+ </div>
1296+ <button
1297+ type="submit"
1298+ className="inline-flex h-9 items-center rounded-md border border-input bg-card px-3 text-xs font-medium text-foreground transition-colors hover:bg-accent/50"
1299+ >
1300+ Search
1301+ </button>
1302+ </form>
1303+ </div>
1304+
1305+ <div className="flex-1 overflow-y-auto p-4">
1306+ <LoadableBlock loadable={props.discovery} loading="Searching…">
1307+ {(result) =>
1308+ result.query.length === 0 ? (
1309+ <EmptyState
1310+ title="Search your tools"
1311+ description="Type a query to find matching tools across this source."
1312+ />
1313+ ) : result.results.length === 0 ? (
1314+ <EmptyState
1315+ title="No results"
1316+ description="Try different search terms."
1317+ />
1318+ ) : (
1319+ <div className="max-w-3xl space-y-2">
1320+ {result.results.map((item, index) => (
1321+ <button
1322+ key={item.path}
1323+ type="button"
1324+ onClick={() => props.onOpenTool(item.path)}
1325+ className="group w-full rounded-lg border border-border bg-card/60 p-3.5 text-left transition-all hover:border-primary/30 hover:shadow-sm"
1326+ >
1327+ <div className="mb-1.5 flex items-center justify-between gap-3">
1328+ <div className="flex min-w-0 items-center gap-2.5">
1329+ <span className="flex size-5 shrink-0 items-center justify-center rounded-md bg-muted text-[10px] font-mono tabular-nums text-muted-foreground/60">
1330+ {index + 1}
1331+ </span>
1332+ <h4 className="truncate font-mono text-[13px] font-medium text-foreground transition-colors group-hover:text-primary">
1333+ {item.path}
1334+ </h4>
1335+ </div>
1336+ <span className="shrink-0 font-mono text-xs tabular-nums text-muted-foreground/50">
1337+ {item.score.toFixed(2)}
1338+ </span>
1339+ </div>
1340+ {item.description && (
1341+ <p className="line-clamp-2 text-[12px] leading-relaxed text-muted-foreground">
1342+ {item.description}
1343+ </p>
1344+ )}
1345+ </button>
1346+ ))}
1347+ </div>
1348+ )
1349+ }
1350+ </LoadableBlock>
1351+ </div>
1352+ </div>
1353+ );
1354+}
1355+
1356+function CopyButton(props: {
1357+ text: string;
1358+ field: string;
1359+ copiedField: string | null;
1360+ onCopy: (text: string, field: string) => void | Promise<void>;
1361+}) {
1362+ return (
1363+ <button
1364+ type="button"
1365+ onClick={() => {
1366+ void props.onCopy(props.text, props.field);
1367+ }}
1368+ className="shrink-0 rounded p-1 text-muted-foreground/30 transition-colors hover:text-muted-foreground"
1369+ title={`Copy ${props.field}`}
1370+ >
1371+ {props.copiedField === props.field ? <IconCheck /> : <IconCopy />}
1372+ </button>
1373+ );
1374+}
1375+
1376+function highlightMatch(text: string, search: string) {
1377+ if (!search.trim()) return text;
1378+ const terms = search.trim().toLowerCase().split(/\s+/);
1379+ const lowerText = text.toLowerCase();
1380+ const ranges: Array<[number, number]> = [];
1381+
1382+ for (const term of terms) {
1383+ let idx = 0;
1384+ while (idx < lowerText.length) {
1385+ const found = lowerText.indexOf(term, idx);
1386+ if (found === -1) break;
1387+ ranges.push([found, found + term.length]);
1388+ idx = found + 1;
1389+ }
1390+ }
1391+
1392+ if (ranges.length === 0) return text;
1393+
1394+ ranges.sort((a, b) => a[0] - b[0]);
1395+ const merged: Array<[number, number]> = [ranges[0]!];
1396+ for (let index = 1; index < ranges.length; index++) {
1397+ const last = merged[merged.length - 1]!;
1398+ const current = ranges[index]!;
1399+ if (current[0] <= last[1]) {
1400+ last[1] = Math.max(last[1], current[1]);
1401+ } else {
1402+ merged.push(current);
1403+ }
1404+ }
1405+
1406+ const parts: Array<{ text: string; hl: boolean }> = [];
1407+ let cursor = 0;
1408+ for (const [start, end] of merged) {
1409+ if (cursor < start) parts.push({ text: text.slice(cursor, start), hl: false });
1410+ parts.push({ text: text.slice(start, end), hl: true });
1411+ cursor = end;
1412+ }
1413+ if (cursor < text.length) parts.push({ text: text.slice(cursor), hl: false });
1414+
1415+ return (
1416+ <>
1417+ {parts.map((part, index) =>
1418+ part.hl ? (
1419+ <mark key={index} className="rounded-sm bg-primary/20 px-px text-foreground">
1420+ {part.text}
1421+ </mark>
1422+ ) : (
1423+ <span key={index}>{part.text}</span>
1424+ ),
1425+ )}
1426+ </>
1427+ );
1428+}
1429+
4391430 export const OpenApiReactPlugin = {
4401431 key: "openapi",
4411432 register(api: FrontendPluginRegisterApi) {
@@ -448,8 +1439,8 @@ export const OpenApiReactPlugin = { · 6 unmodified lines
4481439 renderEditPage: ({ source }) => (
4491440 <OpenApiEditSourcePage source={source} />
4501441 ),
451− renderDetailPage: ({ source }) => (
452− <OpenApiSourceDetailPage source={source} />
1442+ renderDetailPage: ({ source, route }) => (
1443+ <OpenApiSourceDetailPage source={source} route={route} />
4531444 ),
4541445 });
4551446 },
plugins/openapi/sdk/catalog.ts+919−0
@@ -0,0 +1,919 @@
1+import {
2+ CapabilityIdSchema,
3+ DocumentIdSchema,
4+ ExampleSymbolIdSchema,
5+ ExecutableIdSchema,
6+ HeaderSymbolIdSchema,
7+ ParameterSymbolIdSchema,
8+ RequestBodySymbolIdSchema,
9+ ResponseSymbolIdSchema,
10+ ScopeIdSchema,
11+ SecuritySchemeSymbolIdSchema,
12+} from "@executor/ir/ids";
13+import type {
14+ AuthRequirement,
15+ Capability,
16+ ContentSpec,
17+ Executable,
18+ ParameterSymbol,
19+ ResponseSet,
20+ ResponseSymbol,
21+ Scope,
22+ SecuritySchemeSymbol,
23+} from "@executor/ir/model";
24+import {
25+ EXECUTABLE_BINDING_VERSION,
26+ buildCatalogFragment,
27+ docsFrom,
28+ exampleSymbolFromValue,
29+ groupedSchemaForParameter,
30+ interactionForEffect,
31+ mutableRecord,
32+ preferredResponseContentTypes,
33+ provenanceFor,
34+ requestBodySchemaFromInput,
35+ responseSetFromSingleResponse,
36+ responseSetFromVariants,
37+ stableHash,
38+ statusMatchFromHttpStatusCode,
39+ toolPathSegments,
40+ type BaseCatalogOperationInput,
41+ type CatalogFragmentBuilder,
42+ type CatalogSourceDocumentInput,
43+ type JsonSchemaImporter,
44+ type Source,
45+} from "@executor/source-core";
46+
47+import type {
48+ OpenApiHeader,
49+ OpenApiMediaContent,
50+ OpenApiResponseVariant,
51+ OpenApiSecurityRequirement,
52+ OpenApiSecurityScheme,
53+ OpenApiToolProviderData,
54+ OpenApiToolDefinition,
55+} from "./types";
56+import { openApiProviderDataFromDefinition } from "./definitions";
57+
58+export type OpenApiCatalogOperationInput = BaseCatalogOperationInput & {
59+ providerData: OpenApiToolProviderData;
60+};
61+
62+const openApiServerSpecs = (
63+ servers:
64+ | OpenApiToolProviderData["servers"]
65+ | OpenApiToolProviderData["documentServers"]
66+ | undefined,
67+): NonNullable<NonNullable<Scope["defaults"]>["servers"]> | undefined => {
68+ if (!servers || servers.length === 0) {
69+ return undefined;
70+ }
71+
72+ return servers.map((server) => ({
73+ url: server.url,
74+ ...(server.description ? { description: server.description } : {}),
75+ ...(server.variables ? { variables: server.variables } : {}),
76+ }));
77+};
78+
79+const createOperationScope = (input: {
80+ catalog: CatalogFragmentBuilder;
81+ source: Pick<Source, "id">;
82+ documentId: ReturnType<typeof DocumentIdSchema.make>;
83+ parentScopeId: ReturnType<typeof ScopeIdSchema.make>;
84+ operation: OpenApiCatalogOperationInput;
85+ defaults: Scope["defaults"];
86+}) => {
87+ const scopeId = ScopeIdSchema.make(
88+ `scope_${stableHash({
89+ sourceId: input.source.id,
90+ toolId: input.operation.providerData.toolId,
91+ kind: "operation",
92+ })}`,
93+ );
94+
95+ mutableRecord(input.catalog.scopes)[scopeId] = {
96+ id: scopeId,
97+ kind: "operation",
98+ parentId: input.parentScopeId,
99+ name: input.operation.title ?? input.operation.providerData.toolId,
100+ docs: docsFrom({
101+ summary: input.operation.title ?? input.operation.providerData.toolId,
102+ description: input.operation.description ?? undefined,
103+ }),
104+ defaults: input.defaults,
105+ synthetic: false,
106+ provenance: provenanceFor(
107+ input.documentId,
108+ `#/openapi/${input.operation.providerData.toolId}/scope`,
109+ ),
110+ } satisfies Scope;
111+
112+ return scopeId;
113+};
114+
115+const ensureOpenApiSecuritySchemeSymbol = (input: {
116+ catalog: CatalogFragmentBuilder;
117+ source: Pick<Source, "id">;
118+ documentId: ReturnType<typeof DocumentIdSchema.make>;
119+ schemeName: string;
120+ scheme?: OpenApiSecurityScheme;
121+}) => {
122+ const schemeId = SecuritySchemeSymbolIdSchema.make(
123+ `security_${stableHash({
124+ sourceId: input.source.id,
125+ provider: "openapi",
126+ schemeName: input.schemeName,
127+ })}`,
128+ );
129+
130+ if (input.catalog.symbols[schemeId]) {
131+ return schemeId;
132+ }
133+
134+ const scheme = input.scheme;
135+ const httpScheme = scheme?.scheme?.toLowerCase();
136+ const schemeType =
137+ scheme?.schemeType === "apiKey"
138+ ? "apiKey"
139+ : scheme?.schemeType === "oauth2"
140+ ? "oauth2"
141+ : scheme?.schemeType === "http" && httpScheme === "basic"
142+ ? "basic"
143+ : scheme?.schemeType === "http" && httpScheme === "bearer"
144+ ? "bearer"
145+ : scheme?.schemeType === "openIdConnect"
146+ ? "custom"
147+ : scheme?.schemeType === "http"
148+ ? "http"
149+ : "custom";
150+
151+ const oauthFlows = Object.fromEntries(
152+ Object.entries(scheme?.flows ?? {}).map(([flowName, flow]) => [flowName, flow]),
153+ );
154+ const oauthScopes = Object.fromEntries(
155+ Object.entries(scheme?.flows ?? {}).flatMap(([, flow]) =>
156+ Object.entries(flow.scopes ?? {}),
157+ ),
158+ );
159+ const description =
160+ scheme?.description ??
161+ (scheme?.openIdConnectUrl ? `OpenID Connect: ${scheme.openIdConnectUrl}` : null);
162+
163+ mutableRecord(input.catalog.symbols)[schemeId] = {
164+ id: schemeId,
165+ kind: "securityScheme",
166+ schemeType,
167+ ...(docsFrom({
168+ summary: input.schemeName,
169+ description,
170+ })
171+ ? {
172+ docs: docsFrom({
173+ summary: input.schemeName,
174+ description,
175+ })!,
176+ }
177+ : {}),
178+ ...(scheme?.placementIn || scheme?.placementName
179+ ? {
180+ placement: {
181+ ...(scheme?.placementIn ? { in: scheme.placementIn } : {}),
182+ ...(scheme?.placementName ? { name: scheme.placementName } : {}),
183+ },
184+ }
185+ : {}),
186+ ...(schemeType === "apiKey" && scheme?.placementIn && scheme?.placementName
187+ ? {
188+ apiKey: {
189+ in: scheme.placementIn,
190+ name: scheme.placementName,
191+ },
192+ }
193+ : {}),
194+ ...((schemeType === "basic" || schemeType === "bearer" || schemeType === "http") &&
195+ scheme?.scheme
196+ ? {
197+ http: {
198+ scheme: scheme.scheme,
199+ ...(scheme.bearerFormat ? { bearerFormat: scheme.bearerFormat } : {}),
200+ },
201+ }
202+ : {}),
203+ ...(schemeType === "oauth2"
204+ ? {
205+ oauth: {
206+ ...(Object.keys(oauthFlows).length > 0 ? { flows: oauthFlows } : {}),
207+ ...(Object.keys(oauthScopes).length > 0 ? { scopes: oauthScopes } : {}),
208+ },
209+ }
210+ : {}),
211+ ...(schemeType === "custom" ? { custom: {} } : {}),
212+ synthetic: false,
213+ provenance: provenanceFor(
214+ input.documentId,
215+ `#/openapi/securitySchemes/${input.schemeName}`,
216+ ),
217+ } satisfies SecuritySchemeSymbol;
218+
219+ return schemeId;
220+};
221+
222+const openApiAuthRequirementToIr = (input: {
223+ catalog: CatalogFragmentBuilder;
224+ source: Pick<Source, "id">;
225+ documentId: ReturnType<typeof DocumentIdSchema.make>;
226+ authRequirement: OpenApiSecurityRequirement | undefined;
227+ schemesByName: ReadonlyMap<string, OpenApiSecurityScheme>;
228+}): AuthRequirement => {
229+ const requirement = input.authRequirement;
230+ if (!requirement) {
231+ return { kind: "none" };
232+ }
233+
234+ switch (requirement.kind) {
235+ case "none":
236+ return { kind: "none" };
237+ case "scheme": {
238+ const schemeId = ensureOpenApiSecuritySchemeSymbol({
239+ catalog: input.catalog,
240+ source: input.source,
241+ documentId: input.documentId,
242+ schemeName: requirement.schemeName,
243+ scheme: input.schemesByName.get(requirement.schemeName),
244+ });
245+
246+ return {
247+ kind: "scheme",
248+ schemeId,
249+ ...(requirement.scopes && requirement.scopes.length > 0
250+ ? { scopes: [...requirement.scopes] }
251+ : {}),
252+ };
253+ }
254+ case "allOf":
255+ case "anyOf":
256+ return {
257+ kind: requirement.kind,
258+ items: requirement.items.map((item) =>
259+ openApiAuthRequirementToIr({
260+ catalog: input.catalog,
261+ source: input.source,
262+ documentId: input.documentId,
263+ authRequirement: item,
264+ schemesByName: input.schemesByName,
265+ }),
266+ ),
267+ };
268+ }
269+};
270+
271+const contentSpecsFromOpenApiContents = (input: {
272+ catalog: CatalogFragmentBuilder;
273+ source: Pick<Source, "kind">;
274+ documentId: ReturnType<typeof DocumentIdSchema.make>;
275+ importer: JsonSchemaImporter;
276+ rootSchema?: unknown;
277+ contents: ReadonlyArray<OpenApiMediaContent>;
278+ pointerBase: string;
279+}) =>
280+ input.contents.map((content, contentIndex) => {
281+ const exampleIds = (content.examples ?? []).map((example, exampleIndex) =>
282+ exampleSymbolFromValue({
283+ catalog: input.catalog,
284+ source: input.source,
285+ documentId: input.documentId,
286+ pointer: `${input.pointerBase}/content/${contentIndex}/example/${exampleIndex}`,
287+ name: example.label,
288+ summary: example.label,
289+ value: JSON.parse(example.valueJson) as unknown,
290+ }),
291+ );
292+
293+ return {
294+ mediaType: content.mediaType,
295+ ...(content.schema !== undefined
296+ ? {
297+ shapeId: input.importer.importSchema(
298+ content.schema,
299+ `${input.pointerBase}/content/${contentIndex}`,
300+ input.rootSchema,
301+ ),
302+ }
303+ : {}),
304+ ...(exampleIds.length > 0 ? { exampleIds } : {}),
305+ } satisfies ContentSpec;
306+ });
307+
308+const createOpenApiHeaderSymbol = (input: {
309+ catalog: CatalogFragmentBuilder;
310+ source: Pick<Source, "kind">;
311+ documentId: ReturnType<typeof DocumentIdSchema.make>;
312+ importer: JsonSchemaImporter;
313+ rootSchema?: unknown;
314+ pointer: string;
315+ idSeed: Record<string, unknown>;
316+ header: OpenApiHeader;
317+}) => {
318+ const headerId = HeaderSymbolIdSchema.make(`header_${stableHash(input.idSeed)}`);
319+ const exampleIds = (input.header.examples ?? []).map((example, index) =>
320+ exampleSymbolFromValue({
321+ catalog: input.catalog,
322+ source: input.source,
323+ documentId: input.documentId,
324+ pointer: `${input.pointer}/example/${index}`,
325+ name: example.label,
326+ summary: example.label,
327+ value: JSON.parse(example.valueJson) as unknown,
328+ }),
329+ );
330+ const contents = input.header.content
331+ ? contentSpecsFromOpenApiContents({
332+ catalog: input.catalog,
333+ source: input.source,
334+ documentId: input.documentId,
335+ importer: input.importer,
336+ rootSchema: input.rootSchema,
337+ contents: input.header.content,
338+ pointerBase: input.pointer,
339+ })
340+ : undefined;
341+
342+ mutableRecord(input.catalog.symbols)[headerId] = {
343+ id: headerId,
344+ kind: "header",
345+ name: input.header.name,
346+ ...(docsFrom({
347+ description: input.header.description,
348+ })
349+ ? {
350+ docs: docsFrom({
351+ description: input.header.description,
352+ })!,
353+ }
354+ : {}),
355+ ...(typeof input.header.deprecated === "boolean"
356+ ? { deprecated: input.header.deprecated }
357+ : {}),
358+ ...(input.header.schema !== undefined
359+ ? {
360+ schemaShapeId: input.importer.importSchema(
361+ input.header.schema,
362+ input.pointer,
363+ input.rootSchema,
364+ ),
365+ }
366+ : {}),
367+ ...(contents && contents.length > 0 ? { content: contents } : {}),
368+ ...(exampleIds.length > 0 ? { exampleIds } : {}),
369+ ...(input.header.style ? { style: input.header.style } : {}),
370+ ...(typeof input.header.explode === "boolean"
371+ ? { explode: input.header.explode }
372+ : {}),
373+ synthetic: false,
374+ provenance: provenanceFor(input.documentId, input.pointer),
375+ };
376+
377+ return headerId;
378+};
379+
380+const createHttpCapabilityFromOpenApi = (input: {
381+ catalog: CatalogFragmentBuilder;
382+ source: Pick<Source, "id" | "kind" | "name" | "namespace">;
383+ documentId: ReturnType<typeof DocumentIdSchema.make>;
384+ serviceScopeId: ReturnType<typeof ScopeIdSchema.make>;
385+ operation: OpenApiCatalogOperationInput;
386+ importer: JsonSchemaImporter;
387+ rootSchema?: unknown;
388+}) => {
389+ const toolPath = toolPathSegments(input.source, input.operation.providerData.toolId);
390+ const capabilityId = CapabilityIdSchema.make(
391+ `cap_${stableHash({
392+ sourceId: input.source.id,
393+ toolId: input.operation.providerData.toolId,
394+ })}`,
395+ );
396+ const executableId = ExecutableIdSchema.make(
397+ `exec_${stableHash({
398+ sourceId: input.source.id,
399+ toolId: input.operation.providerData.toolId,
400+ protocol: "http",
401+ })}`,
402+ );
403+ const inputSchema = input.operation.inputSchema ?? {};
404+ const outputSchema = input.operation.outputSchema ?? {};
405+ const exampleIds: Array<ReturnType<typeof ExampleSymbolIdSchema.make>> = [];
406+ const schemesByName = new Map(
407+ (input.operation.providerData.securitySchemes ?? []).map((scheme) => [
408+ scheme.schemeName,
409+ scheme,
410+ ]),
411+ );
412+
413+ input.operation.providerData.invocation.parameters.forEach((parameter) => {
414+ const parameterId = ParameterSymbolIdSchema.make(
415+ `param_${stableHash({
416+ capabilityId,
417+ location: parameter.location,
418+ name: parameter.name,
419+ })}`,
420+ );
421+ const parameterSchema = groupedSchemaForParameter(
422+ inputSchema,
423+ parameter.location,
424+ parameter.name,
425+ );
426+ const matchingDocs = input.operation.providerData.documentation?.parameters.find(
427+ (candidate) =>
428+ candidate.name === parameter.name && candidate.location === parameter.location,
429+ );
430+ const parameterExampleIds = (matchingDocs?.examples ?? []).map((example, index) =>
431+ exampleSymbolFromValue({
432+ catalog: input.catalog,
433+ source: input.source,
434+ documentId: input.documentId,
435+ pointer: `#/openapi/${input.operation.providerData.toolId}/parameter/${parameter.location}/${parameter.name}/example/${index}`,
436+ name: example.label,
437+ summary: example.label,
438+ value: JSON.parse(example.valueJson) as unknown,
439+ }),
440+ );
441+ exampleIds.push(...parameterExampleIds);
442+ const parameterContent = parameter.content
443+ ? contentSpecsFromOpenApiContents({
444+ catalog: input.catalog,
445+ source: input.source,
446+ documentId: input.documentId,
447+ importer: input.importer,
448+ rootSchema: input.rootSchema,
449+ contents: parameter.content,
450+ pointerBase: `#/openapi/${input.operation.providerData.toolId}/parameter/${parameter.location}/${parameter.name}`,
451+ })
452+ : undefined;
453+ mutableRecord(input.catalog.symbols)[parameterId] = {
454+ id: parameterId,
455+ kind: "parameter",
456+ name: parameter.name,
457+ location: parameter.location,
458+ required: parameter.required,
459+ ...(docsFrom({
460+ description: matchingDocs?.description ?? null,
461+ })
462+ ? {
463+ docs: docsFrom({
464+ description: matchingDocs?.description ?? null,
465+ })!,
466+ }
467+ : {}),
468+ ...(parameterSchema !== undefined && (!parameterContent || parameterContent.length === 0)
469+ ? {
470+ schemaShapeId: input.importer.importSchema(
471+ parameterSchema,
472+ `#/openapi/${input.operation.providerData.toolId}/parameter/${parameter.location}/${parameter.name}`,
473+ input.rootSchema,
474+ ),
475+ }
476+ : {}),
477+ ...(parameterContent && parameterContent.length > 0 ? { content: parameterContent } : {}),
478+ ...(parameterExampleIds.length > 0 ? { exampleIds: parameterExampleIds } : {}),
479+ ...(parameter.style ? { style: parameter.style } : {}),
480+ ...(typeof parameter.explode === "boolean" ? { explode: parameter.explode } : {}),
481+ ...(typeof parameter.allowReserved === "boolean"
482+ ? { allowReserved: parameter.allowReserved }
483+ : {}),
484+ synthetic: false,
485+ provenance: provenanceFor(
486+ input.documentId,
487+ `#/openapi/${input.operation.providerData.toolId}/parameter/${parameter.location}/${parameter.name}`,
488+ ),
489+ } satisfies ParameterSymbol;
490+ });
491+
492+ const requestBodyId = input.operation.providerData.invocation.requestBody
493+ ? RequestBodySymbolIdSchema.make(`request_body_${stableHash({ capabilityId })}`)
494+ : undefined;
495+
496+ if (requestBodyId) {
497+ const requestBody = input.operation.providerData.invocation.requestBody;
498+ const requestBodySchema = requestBodySchemaFromInput(inputSchema);
499+ const requestBodyContents = requestBody?.contents
500+ ? contentSpecsFromOpenApiContents({
501+ catalog: input.catalog,
502+ source: input.source,
503+ documentId: input.documentId,
504+ importer: input.importer,
505+ rootSchema: input.rootSchema,
506+ contents: requestBody.contents,
507+ pointerBase: `#/openapi/${input.operation.providerData.toolId}/requestBody`,
508+ })
509+ : undefined;
510+ const requestBodyExampleIds =
511+ requestBodyContents?.flatMap((content) => content.exampleIds ?? []) ??
512+ (input.operation.providerData.documentation?.requestBody?.examples ?? []).map(
513+ (example, index) =>
514+ exampleSymbolFromValue({
515+ catalog: input.catalog,
516+ source: input.source,
517+ documentId: input.documentId,
518+ pointer: `#/openapi/${input.operation.providerData.toolId}/requestBody/example/${index}`,
519+ name: example.label,
520+ summary: example.label,
521+ value: JSON.parse(example.valueJson) as unknown,
522+ }),
523+ );
524+ exampleIds.push(...requestBodyExampleIds);
525+ const contents: ContentSpec[] =
526+ requestBodyContents && requestBodyContents.length > 0
527+ ? requestBodyContents
528+ : preferredResponseContentTypes(
529+ input.operation.providerData.invocation.requestBody?.contentTypes,
530+ ).map((mediaType) => ({
531+ mediaType,
532+ ...(requestBodySchema !== undefined
533+ ? {
534+ shapeId: input.importer.importSchema(
535+ requestBodySchema,
536+ `#/openapi/${input.operation.providerData.toolId}/requestBody`,
537+ input.rootSchema,
538+ ),
539+ }
540+ : {}),
541+ ...(requestBodyExampleIds.length > 0
542+ ? { exampleIds: requestBodyExampleIds }
543+ : {}),
544+ }));
545+
546+ mutableRecord(input.catalog.symbols)[requestBodyId] = {
547+ id: requestBodyId,
548+ kind: "requestBody",
549+ ...(docsFrom({
550+ description:
551+ input.operation.providerData.documentation?.requestBody?.description ?? null,
552+ })
553+ ? {
554+ docs: docsFrom({
555+ description:
556+ input.operation.providerData.documentation?.requestBody?.description ??
557+ null,
558+ })!,
559+ }
560+ : {}),
561+ required: input.operation.providerData.invocation.requestBody?.required ?? false,
562+ contents,
563+ synthetic: false,
564+ provenance: provenanceFor(
565+ input.documentId,
566+ `#/openapi/${input.operation.providerData.toolId}/requestBody`,
567+ ),
568+ };
569+ }
570+
571+ const openApiResponseVariants = input.operation.providerData.responses ?? [];
572+ const responseSetId =
573+ openApiResponseVariants.length > 0
574+ ? responseSetFromVariants({
575+ catalog: input.catalog,
576+ variants: openApiResponseVariants.map((response, responseIndex) => {
577+ const responseId = ResponseSymbolIdSchema.make(
578+ `response_${stableHash({
579+ capabilityId,
580+ statusCode: response.statusCode,
581+ responseIndex,
582+ })}`,
583+ );
584+ const responseExampleIds = (response.examples ?? []).map((example, index) =>
585+ exampleSymbolFromValue({
586+ catalog: input.catalog,
587+ source: input.source,
588+ documentId: input.documentId,
589+ pointer: `#/openapi/${input.operation.providerData.toolId}/responses/${response.statusCode}/example/${index}`,
590+ name: example.label,
591+ summary: example.label,
592+ value: JSON.parse(example.valueJson) as unknown,
593+ }),
594+ );
595+ exampleIds.push(...responseExampleIds);
596+
597+ const contents =
598+ response.contents && response.contents.length > 0
599+ ? contentSpecsFromOpenApiContents({
600+ catalog: input.catalog,
601+ source: input.source,
602+ documentId: input.documentId,
603+ importer: input.importer,
604+ rootSchema: input.rootSchema,
605+ contents: response.contents,
606+ pointerBase: `#/openapi/${input.operation.providerData.toolId}/responses/${response.statusCode}`,
607+ })
608+ : (() => {
609+ const responseShapeId =
610+ response.schema !== undefined
611+ ? input.importer.importSchema(
612+ response.schema,
613+ `#/openapi/${input.operation.providerData.toolId}/responses/${response.statusCode}`,
614+ input.rootSchema,
615+ )
616+ : undefined;
617+ const preferredContentTypes = preferredResponseContentTypes(
618+ response.contentTypes,
619+ );
620+
621+ return preferredContentTypes.length > 0
622+ ? preferredContentTypes.map((mediaType, contentIndex) => ({
623+ mediaType,
624+ ...(responseShapeId !== undefined && contentIndex === 0
625+ ? { shapeId: responseShapeId }
626+ : {}),
627+ ...(responseExampleIds.length > 0 && contentIndex === 0
628+ ? { exampleIds: responseExampleIds }
629+ : {}),
630+ }))
631+ : undefined;
632+ })();
633+ const headerIds = (response.headers ?? []).map((header, headerIndex) =>
634+ createOpenApiHeaderSymbol({
635+ catalog: input.catalog,
636+ source: input.source,
637+ documentId: input.documentId,
638+ importer: input.importer,
639+ rootSchema: input.rootSchema,
640+ pointer: `#/openapi/${input.operation.providerData.toolId}/responses/${response.statusCode}/headers/${header.name}`,
641+ idSeed: {
642+ capabilityId,
643+ responseId,
644+ headerIndex,
645+ headerName: header.name,
646+ },
647+ header,
648+ }),
649+ );
650+
651+ mutableRecord(input.catalog.symbols)[responseId] = {
652+ id: responseId,
653+ kind: "response",
654+ ...(docsFrom({
655+ description:
656+ response.description ??
657+ (responseIndex === 0 ? input.operation.description : null),
658+ })
659+ ? {
660+ docs: docsFrom({
661+ description:
662+ response.description ??
663+ (responseIndex === 0 ? input.operation.description : null),
664+ })!,
665+ }
666+ : {}),
667+ ...(headerIds.length > 0 ? { headerIds } : {}),
668+ ...(contents && contents.length > 0 ? { contents } : {}),
669+ synthetic: false,
670+ provenance: provenanceFor(
671+ input.documentId,
672+ `#/openapi/${input.operation.providerData.toolId}/responses/${response.statusCode}`,
673+ ),
674+ } satisfies ResponseSymbol;
675+
676+ return {
677+ match: statusMatchFromHttpStatusCode(response.statusCode),
678+ responseId,
679+ } satisfies ResponseSet["variants"][number];
680+ }),
681+ provenance: provenanceFor(
682+ input.documentId,
683+ `#/openapi/${input.operation.providerData.toolId}/responseSet`,
684+ ),
685+ })
686+ : (() => {
687+ const responseId = ResponseSymbolIdSchema.make(
688+ `response_${stableHash({ capabilityId })}`,
689+ );
690+ const responseExampleIds = (
691+ input.operation.providerData.documentation?.response?.examples ?? []
692+ ).map((example, index) =>
693+ exampleSymbolFromValue({
694+ catalog: input.catalog,
695+ source: input.source,
696+ documentId: input.documentId,
697+ pointer: `#/openapi/${input.operation.providerData.toolId}/response/example/${index}`,
698+ name: example.label,
699+ summary: example.label,
700+ value: JSON.parse(example.valueJson) as unknown,
701+ }),
702+ );
703+ exampleIds.push(...responseExampleIds);
704+ mutableRecord(input.catalog.symbols)[responseId] = {
705+ id: responseId,
706+ kind: "response",
707+ ...(docsFrom({
708+ description:
709+ input.operation.providerData.documentation?.response?.description ??
710+ input.operation.description,
711+ })
712+ ? {
713+ docs: docsFrom({
714+ description:
715+ input.operation.providerData.documentation?.response
716+ ?.description ?? input.operation.description,
717+ })!,
718+ }
719+ : {}),
720+ contents: [
721+ {
722+ mediaType:
723+ preferredResponseContentTypes(
724+ input.operation.providerData.documentation?.response?.contentTypes,
725+ )[0] ?? "application/json",
726+ ...(input.operation.outputSchema !== undefined
727+ ? {
728+ shapeId: input.importer.importSchema(
729+ outputSchema,
730+ `#/openapi/${input.operation.providerData.toolId}/response`,
731+ ),
732+ }
733+ : {}),
734+ ...(responseExampleIds.length > 0
735+ ? { exampleIds: responseExampleIds }
736+ : {}),
737+ },
738+ ],
739+ synthetic: false,
740+ provenance: provenanceFor(
741+ input.documentId,
742+ `#/openapi/${input.operation.providerData.toolId}/response`,
743+ ),
744+ } satisfies ResponseSymbol;
745+
746+ return responseSetFromSingleResponse({
747+ catalog: input.catalog,
748+ responseId,
749+ provenance: provenanceFor(
750+ input.documentId,
751+ `#/openapi/${input.operation.providerData.toolId}/responseSet`,
752+ ),
753+ });
754+ })();
755+
756+ const callShapeId =
757+ input.operation.inputSchema !== undefined
758+ ? input.importer.importSchema(
759+ input.operation.inputSchema,
760+ `#/openapi/${input.operation.providerData.toolId}/call`,
761+ input.operation.inputSchema,
762+ )
763+ : input.importer.importSchema(
764+ {
765+ type: "object",
766+ additionalProperties: false,
767+ },
768+ `#/openapi/${input.operation.providerData.toolId}/call`,
769+ );
770+
771+ const executable: Executable = {
772+ id: executableId,
773+ capabilityId,
774+ scopeId: (() => {
775+ const operationServers = openApiServerSpecs(input.operation.providerData.servers);
776+ if (!operationServers || operationServers.length === 0) {
777+ return input.serviceScopeId;
778+ }
779+
780+ return createOperationScope({
781+ catalog: input.catalog,
782+ source: input.source,
783+ documentId: input.documentId,
784+ parentScopeId: input.serviceScopeId,
785+ operation: input.operation,
786+ defaults: {
787+ servers: operationServers,
788+ },
789+ });
790+ })(),
791+ pluginKey: "openapi",
792+ bindingVersion: EXECUTABLE_BINDING_VERSION,
793+ binding: input.operation.providerData,
794+ projection: {
795+ responseSetId,
796+ callShapeId,
797+ },
798+ display: {
799+ protocol: "http",
800+ method: input.operation.providerData.invocation.method.toUpperCase(),
801+ pathTemplate: input.operation.providerData.invocation.pathTemplate,
802+ operationId: input.operation.providerData.operationId ?? null,
803+ group: input.operation.providerData.group,
804+ leaf: input.operation.providerData.leaf,
805+ rawToolId: input.operation.providerData.rawToolId,
806+ title: input.operation.title ?? null,
807+ summary: input.operation.description ?? null,
808+ },
809+ synthetic: false,
810+ provenance: provenanceFor(
811+ input.documentId,
812+ `#/openapi/${input.operation.providerData.toolId}/executable`,
813+ ),
814+ };
815+ mutableRecord(input.catalog.executables)[executableId] = executable;
816+
817+ const effect = input.operation.effect;
818+ const auth = openApiAuthRequirementToIr({
819+ catalog: input.catalog,
820+ source: input.source,
821+ documentId: input.documentId,
822+ authRequirement: input.operation.providerData.authRequirement,
823+ schemesByName,
824+ });
825+ mutableRecord(input.catalog.capabilities)[capabilityId] = {
826+ id: capabilityId,
827+ serviceScopeId: input.serviceScopeId,
828+ surface: {
829+ toolPath,
830+ ...(input.operation.title ? { title: input.operation.title } : {}),
831+ ...(input.operation.description ? { summary: input.operation.description } : {}),
832+ ...(input.operation.providerData.tags.length > 0
833+ ? { tags: input.operation.providerData.tags }
834+ : {}),
835+ },
836+ semantics: {
837+ effect,
838+ safe: effect === "read",
839+ idempotent: effect === "read" || effect === "delete",
840+ destructive: effect === "delete",
841+ },
842+ auth,
843+ interaction: interactionForEffect(effect),
844+ executableIds: [executableId],
845+ ...(exampleIds.length > 0 ? { exampleIds } : {}),
846+ synthetic: false,
847+ provenance: provenanceFor(
848+ input.documentId,
849+ `#/openapi/${input.operation.providerData.toolId}/capability`,
850+ ),
851+ } satisfies Capability;
852+};
853+
854+export const openApiCatalogOperationFromDefinition = (definition: OpenApiToolDefinition) => {
855+ const providerData = openApiProviderDataFromDefinition(definition);
856+ const method = definition.method.toUpperCase();
857+
858+ return {
859+ toolId: definition.toolId,
860+ title: definition.name,
861+ description: definition.description,
862+ effect:
863+ method === "GET" || method === "HEAD"
864+ ? "read"
865+ : method === "DELETE"
866+ ? "delete"
867+ : "write",
868+ inputSchema: definition.inputSchema,
869+ outputSchema: definition.outputSchema,
870+ providerData,
871+ } satisfies OpenApiCatalogOperationInput;
872+};
873+
874+export const createOpenApiCatalogFragment = (input: {
875+ source: Source;
876+ documents: readonly CatalogSourceDocumentInput[];
877+ operations: readonly OpenApiCatalogOperationInput[];
878+}) => {
879+ const rootSchema = (() => {
880+ const primaryDocumentText = input.documents[0]?.contentText;
881+ if (!primaryDocumentText) {
882+ return undefined;
883+ }
884+
885+ try {
886+ return JSON.parse(primaryDocumentText) as unknown;
887+ } catch {
888+ return undefined;
889+ }
890+ })();
891+
892+ return buildCatalogFragment({
893+ source: input.source,
894+ documents: input.documents,
895+ resourceDialectUri: "https://json-schema.org/draft/2020-12/schema",
896+ serviceScopeDefaults: (() => {
897+ const documentServers = openApiServerSpecs(
898+ input.operations.find(
899+ (operation) => (operation.providerData.documentServers ?? []).length > 0,
900+ )?.providerData.documentServers,
901+ );
902+
903+ return documentServers ? { servers: documentServers } : undefined;
904+ })(),
905+ registerOperations: ({ catalog, documentId, serviceScopeId, importer }) => {
906+ for (const operation of input.operations) {
907+ createHttpCapabilityFromOpenApi({
908+ catalog,
909+ source: input.source,
910+ documentId,
911+ serviceScopeId,
912+ operation,
913+ importer,
914+ rootSchema,
915+ });
916+ }
917+ },
918+ });
919+};
plugins/openapi/sdk/definitions.ts+249−0
@@ -0,0 +1,249 @@
1+import type {
2+ OpenApiExtractedTool,
3+ OpenApiToolDefinition,
4+ OpenApiToolManifest,
5+ OpenApiToolProviderData,
6+} from "./types";
7+
8+const VERSION_SEGMENT_REGEX = /^v\d+(?:[._-]\d+)?$/i;
9+const IGNORED_PATH_SEGMENTS = new Set(["api"]);
10+
11+const splitWords = (value: string): Array<string> =>
12+ value
13+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
14+ .replace(/([A-Z]+)([A-Z][a-z0-9]+)/g, "$1 $2")
15+ .replace(/[^a-zA-Z0-9]+/g, " ")
16+ .trim()
17+ .split(/\s+/)
18+ .filter((part) => part.length > 0);
19+
20+const normalizeWord = (value: string): string => value.toLowerCase();
21+
22+const toCamelCase = (value: string): string => {
23+ const words = splitWords(value).map(normalizeWord);
24+ if (words.length === 0) {
25+ return "tool";
26+ }
27+
28+ const [first, ...rest] = words;
29+ return `${first}${rest
30+ .map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`)
31+ .join("")}`;
32+};
33+
34+const toPascalCase = (value: string): string => {
35+ const camel = toCamelCase(value);
36+ return `${camel[0]?.toUpperCase() ?? ""}${camel.slice(1)}`;
37+};
38+
39+const normalizeGroupSegment = (value: string | undefined): string | null => {
40+ const candidate = value?.trim();
41+ if (!candidate) {
42+ return null;
43+ }
44+
45+ return toCamelCase(candidate);
46+};
47+
48+const pathSegmentsFromTemplate = (pathTemplate: string): Array<string> =>
49+ pathTemplate
50+ .split("/")
51+ .map((segment) => segment.trim())
52+ .filter((segment) => segment.length > 0);
53+
54+const isPathParameterSegment = (segment: string): boolean =>
55+ segment.startsWith("{") && segment.endsWith("}");
56+
57+const deriveVersionSegment = (pathTemplate: string): string | undefined =>
58+ pathSegmentsFromTemplate(pathTemplate)
59+ .map((segment) => segment.toLowerCase())
60+ .find((segment) => VERSION_SEGMENT_REGEX.test(segment));
61+
62+const derivePathGroup = (pathTemplate: string): string => {
63+ for (const segment of pathSegmentsFromTemplate(pathTemplate)) {
64+ const lower = segment.toLowerCase();
65+ if (VERSION_SEGMENT_REGEX.test(lower)) {
66+ continue;
67+ }
68+ if (IGNORED_PATH_SEGMENTS.has(lower)) {
69+ continue;
70+ }
71+ if (isPathParameterSegment(segment)) {
72+ continue;
73+ }
74+
75+ return normalizeGroupSegment(segment) ?? "root";
76+ }
77+
78+ return "root";
79+};
80+
81+const splitOperationIdSegments = (value: string): Array<string> =>
82+ value
83+ .split(/[/.]+/)
84+ .map((segment) => segment.trim())
85+ .filter((segment) => segment.length > 0);
86+
87+const deriveLeafSeed = (
88+ tool: OpenApiExtractedTool,
89+ group: string,
90+): string => {
91+ const preferredSource = tool.operationId ?? tool.toolId;
92+ const segments = splitOperationIdSegments(preferredSource);
93+ if (segments.length > 1) {
94+ const [first, ...rest] = segments;
95+ if ((normalizeGroupSegment(first) ?? first) === group && rest.length > 0) {
96+ return rest.join(" ");
97+ }
98+ }
99+
100+ return preferredSource;
101+};
102+
103+const fallbackLeafSeed = (
104+ tool: OpenApiExtractedTool,
105+ group: string,
106+): string => {
107+ const relevantSegments = pathSegmentsFromTemplate(tool.path)
108+ .filter((segment) => !VERSION_SEGMENT_REGEX.test(segment.toLowerCase()))
109+ .filter((segment) => !IGNORED_PATH_SEGMENTS.has(segment.toLowerCase()))
110+ .filter((segment) => !isPathParameterSegment(segment))
111+ .map((segment) => normalizeGroupSegment(segment) ?? segment)
112+ .filter((segment) => segment !== group);
113+
114+ const segmentSuffix = relevantSegments
115+ .map((segment) => toPascalCase(segment))
116+ .join("");
117+
118+ return `${tool.method}${segmentSuffix || "Operation"}`;
119+};
120+
121+const deriveLeaf = (tool: OpenApiExtractedTool, group: string): string => {
122+ const preferred = toCamelCase(deriveLeafSeed(tool, group));
123+ if (preferred.length > 0 && preferred !== group) {
124+ return preferred;
125+ }
126+
127+ return toCamelCase(fallbackLeafSeed(tool, group));
128+};
129+
130+const defaultDescription = (tool: OpenApiExtractedTool): string =>
131+ tool.description ?? `${tool.method.toUpperCase()} ${tool.path}`;
132+
133+const withResolvedToolIds = (
134+ definitions: Array<Omit<OpenApiToolDefinition, "toolId">>,
135+): Array<OpenApiToolDefinition> => {
136+ const staged = definitions.map((definition) => ({
137+ ...definition,
138+ toolId: `${definition.group}.${definition.leaf}`,
139+ }));
140+
141+ const applyCandidates = (
142+ candidates: Array<OpenApiToolDefinition>,
143+ factory: (definition: OpenApiToolDefinition) => string,
144+ ) => {
145+ const byToolId = new Map<string, Array<OpenApiToolDefinition>>();
146+ for (const candidate of candidates) {
147+ const bucket = byToolId.get(candidate.toolId) ?? [];
148+ bucket.push(candidate);
149+ byToolId.set(candidate.toolId, bucket);
150+ }
151+
152+ for (const bucket of byToolId.values()) {
153+ if (bucket.length < 2) {
154+ continue;
155+ }
156+
157+ for (const definition of bucket) {
158+ definition.toolId = factory(definition);
159+ }
160+ }
161+ };
162+
163+ applyCandidates(staged, (definition) =>
164+ definition.versionSegment
165+ ? `${definition.group}.${definition.versionSegment}.${definition.leaf}`
166+ : definition.toolId,
167+ );
168+
169+ applyCandidates(staged, (definition) => {
170+ const prefix = definition.versionSegment
171+ ? `${definition.group}.${definition.versionSegment}`
172+ : definition.group;
173+ return `${prefix}.${definition.leaf}${toPascalCase(definition.method)}`;
174+ });
175+
176+ applyCandidates(staged, (definition) => {
177+ const prefix = definition.versionSegment
178+ ? `${definition.group}.${definition.versionSegment}`
179+ : definition.group;
180+ return `${prefix}.${definition.leaf}${toPascalCase(
181+ definition.method,
182+ )}${definition.operationHash.slice(0, 8)}`;
183+ });
184+
185+ return staged;
186+};
187+
188+export const compileOpenApiToolDefinitions = (
189+ manifest: OpenApiToolManifest,
190+): Array<OpenApiToolDefinition> => {
191+ const definitions = manifest.tools.map((tool) => {
192+ const group =
193+ normalizeGroupSegment(tool.tags[0]) ?? derivePathGroup(tool.path);
194+ const leaf = deriveLeaf(tool, group);
195+
196+ return {
197+ rawToolId: tool.toolId,
198+ operationId: tool.operationId,
199+ name: tool.name,
200+ description: defaultDescription(tool),
201+ group,
202+ leaf,
203+ tags: [...tool.tags],
204+ versionSegment: deriveVersionSegment(tool.path),
205+ method: tool.method,
206+ path: tool.path,
207+ invocation: tool.invocation,
208+ operationHash: tool.operationHash,
209+ inputSchema: tool.inputSchema,
210+ outputSchema: tool.outputSchema,
211+ documentation: tool.documentation,
212+ responses: tool.responses,
213+ authRequirement: tool.authRequirement,
214+ securitySchemes: tool.securitySchemes,
215+ documentServers: tool.documentServers,
216+ servers: tool.servers,
217+ } satisfies Omit<OpenApiToolDefinition, "toolId">;
218+ });
219+
220+ return withResolvedToolIds(definitions).sort(
221+ (left, right) =>
222+ left.toolId.localeCompare(right.toolId) ||
223+ left.rawToolId.localeCompare(right.rawToolId) ||
224+ left.operationHash.localeCompare(right.operationHash),
225+ );
226+};
227+
228+export const openApiProviderDataFromDefinition = (
229+ definition: OpenApiToolDefinition,
230+): OpenApiToolProviderData => ({
231+ kind: "openapi",
232+ toolId: definition.toolId,
233+ rawToolId: definition.rawToolId,
234+ operationId: definition.operationId,
235+ group: definition.group,
236+ leaf: definition.leaf,
237+ tags: definition.tags,
238+ versionSegment: definition.versionSegment,
239+ method: definition.method,
240+ path: definition.path,
241+ operationHash: definition.operationHash,
242+ invocation: definition.invocation,
243+ documentation: definition.documentation,
244+ responses: definition.responses,
245+ authRequirement: definition.authRequirement,
246+ securitySchemes: definition.securitySchemes,
247+ documentServers: definition.documentServers,
248+ servers: definition.servers,
249+});
plugins/openapi/sdk/document.ts+32−0
@@ -0,0 +1,32 @@
1+import YAML from "yaml";
2+
3+import type { OpenApiJsonObject } from "./types";
4+
5+const isOpenApiJsonObject = (value: unknown): value is OpenApiJsonObject =>
6+ typeof value === "object" && value !== null && !Array.isArray(value);
7+
8+const parseJsonDocument = (input: string): unknown => JSON.parse(input);
9+
10+const parseYamlDocument = (input: string): unknown => YAML.parse(input);
11+
12+const parseDocument = (input: string): unknown => {
13+ try {
14+ return parseJsonDocument(input);
15+ } catch {
16+ return parseYamlDocument(input);
17+ }
18+};
19+
20+export const parseOpenApiDocument = (input: string): OpenApiJsonObject => {
21+ const text = input.trim();
22+ if (text.length === 0) {
23+ throw new Error("OpenAPI document is empty");
24+ }
25+
26+ const parsed = parseDocument(text);
27+ if (!isOpenApiJsonObject(parsed)) {
28+ throw new Error("OpenAPI document must parse to an object");
29+ }
30+
31+ return parsed;
32+};
plugins/openapi/sdk/extraction.ts+886−0
@@ -0,0 +1,886 @@
1+import * as Effect from "effect/Effect";
2+
3+import { contentHash, stableHash } from "@executor/source-core";
4+
5+import { parseOpenApiDocument } from "./document";
6+import type {
7+ OpenApiExample,
8+ OpenApiExtractedTool,
9+ OpenApiHeader,
10+ OpenApiJsonObject,
11+ OpenApiMediaContent,
12+ OpenApiResponseVariant,
13+ OpenApiSecurityRequirement,
14+ OpenApiSecurityScheme,
15+ OpenApiServer,
16+ OpenApiToolDocumentation,
17+ OpenApiToolManifest,
18+ OpenApiToolParameter,
19+ OpenApiToolRequestBody,
20+} from "./types";
21+import { OPEN_API_HTTP_METHODS, type OpenApiHttpMethod } from "./types";
22+
23+const asObject = (value: unknown): Record<string, unknown> =>
24+ value !== null && typeof value === "object" && !Array.isArray(value)
25+ ? (value as Record<string, unknown>)
26+ : {};
27+
28+const asArray = (value: unknown): ReadonlyArray<unknown> =>
29+ Array.isArray(value) ? value : [];
30+
31+const asTrimmedString = (value: unknown): string | undefined =>
32+ typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
33+
34+const stableJsonValue = (value: unknown): unknown => {
35+ if (Array.isArray(value)) {
36+ return value.map((entry) => stableJsonValue(entry));
37+ }
38+
39+ if (value !== null && typeof value === "object") {
40+ return Object.fromEntries(
41+ Object.entries(value as Record<string, unknown>)
42+ .sort(([left], [right]) => left.localeCompare(right))
43+ .map(([key, entry]) => [key, stableJsonValue(entry)]),
44+ );
45+ }
46+
47+ return value;
48+};
49+
50+const stableJsonStringify = (value: unknown): string =>
51+ JSON.stringify(stableJsonValue(value));
52+
53+const resolvePointerSegment = (segment: string): string =>
54+ segment.replaceAll("~1", "/").replaceAll("~0", "~");
55+
56+const resolveLocalRef = (
57+ document: OpenApiJsonObject,
58+ value: unknown,
59+ activeRefs: ReadonlySet<string> = new Set<string>(),
60+): unknown => {
61+ const object = asObject(value);
62+ const ref = typeof object.$ref === "string" ? object.$ref : null;
63+ if (!ref || !ref.startsWith("#/") || activeRefs.has(ref)) {
64+ return value;
65+ }
66+
67+ const resolved = ref
68+ .slice(2)
69+ .split("/")
70+ .reduce<unknown>((current, segment) => {
71+ if (current === undefined || current === null) {
72+ return undefined;
73+ }
74+
75+ return asObject(current)[resolvePointerSegment(segment)];
76+ }, document);
77+
78+ if (resolved === undefined) {
79+ return value;
80+ }
81+
82+ const nextActiveRefs = new Set(activeRefs);
83+ nextActiveRefs.add(ref);
84+
85+ const resolvedObject = asObject(
86+ resolveLocalRef(document, resolved, nextActiveRefs),
87+ );
88+ const { $ref: _ignoredRef, ...rest } = object;
89+
90+ return Object.keys(rest).length > 0 ? { ...resolvedObject, ...rest } : resolvedObject;
91+};
92+
93+const preferredContentEntry = (
94+ content: unknown,
95+): readonly [string, Record<string, unknown>] | undefined => {
96+ const entries = Object.entries(asObject(content))
97+ .sort(([left], [right]) => left.localeCompare(right))
98+ .map(([mediaType, value]) => [mediaType, asObject(value)] as const);
99+
100+ return (
101+ entries.find(([mediaType]) => mediaType === "application/json") ??
102+ entries.find(([mediaType]) => mediaType.toLowerCase().includes("+json")) ??
103+ entries.find(([mediaType]) => mediaType.toLowerCase().includes("json")) ??
104+ entries[0]
105+ );
106+};
107+
108+const contentSchemaFromOperationContent = (
109+ document: OpenApiJsonObject,
110+ content: unknown,
111+): unknown | undefined => {
112+ const preferred = preferredContentEntry(content);
113+ return preferred?.[1].schema === undefined
114+ ? undefined
115+ : resolveLocalRef(document, preferred[1].schema);
116+};
117+
118+const examplesFromValue = (
119+ value: unknown,
120+ input: {
121+ label?: string;
122+ mediaType?: string;
123+ } = {},
124+): Array<OpenApiExample> => {
125+ const record = asObject(value);
126+ const examples: Array<OpenApiExample> = [];
127+
128+ if (record.example !== undefined) {
129+ examples.push({
130+ valueJson: stableJsonStringify(record.example),
131+ ...(input.mediaType ? { mediaType: input.mediaType } : {}),
132+ ...(input.label ? { label: input.label } : {}),
133+ });
134+ }
135+
136+ const exampleEntries = Object.entries(asObject(record.examples)).sort(([left], [right]) =>
137+ left.localeCompare(right),
138+ );
139+ for (const [key, exampleValue] of exampleEntries) {
140+ const exampleRecord = asObject(exampleValue);
141+ examples.push({
142+ valueJson: stableJsonStringify(
143+ exampleRecord.value !== undefined ? exampleRecord.value : exampleValue,
144+ ),
145+ ...(input.mediaType ? { mediaType: input.mediaType } : {}),
146+ label: key,
147+ });
148+ }
149+
150+ return examples;
151+};
152+
153+const examplesFromSchema = (schema: unknown): Array<OpenApiExample> =>
154+ examplesFromValue(schema);
155+
156+const examplesFromMediaType = (
157+ mediaType: string,
158+ mediaTypeRecord: Record<string, unknown>,
159+): Array<OpenApiExample> => {
160+ const direct = examplesFromValue(mediaTypeRecord, { mediaType });
161+ if (direct.length > 0) {
162+ return direct;
163+ }
164+
165+ return examplesFromSchema(mediaTypeRecord.schema).map((example) => ({
166+ ...example,
167+ mediaType,
168+ }));
169+};
170+
171+const contentEntriesFromContent = (
172+ document: OpenApiJsonObject,
173+ content: unknown,
174+): ReadonlyArray<OpenApiMediaContent> =>
175+ Object.entries(asObject(content))
176+ .sort(([left], [right]) => left.localeCompare(right))
177+ .map(([mediaType, mediaValue]) => {
178+ const mediaRecord = asObject(resolveLocalRef(document, mediaValue));
179+ const examples = examplesFromMediaType(mediaType, mediaRecord);
180+
181+ return {
182+ mediaType,
183+ ...(mediaRecord.schema !== undefined
184+ ? { schema: resolveLocalRef(document, mediaRecord.schema) }
185+ : {}),
186+ ...(examples.length > 0 ? { examples } : {}),
187+ };
188+ });
189+
190+const headerFromValue = (
191+ document: OpenApiJsonObject,
192+ name: string,
193+ value: unknown,
194+): OpenApiHeader | undefined => {
195+ const header = asObject(resolveLocalRef(document, value));
196+ if (Object.keys(header).length === 0) {
197+ return undefined;
198+ }
199+
200+ const content = contentEntriesFromContent(document, header.content);
201+ const directExamples = content.length > 0 ? [] : examplesFromValue(header);
202+
203+ return {
204+ name,
205+ ...(asTrimmedString(header.description)
206+ ? { description: asTrimmedString(header.description) }
207+ : {}),
208+ ...(typeof header.required === "boolean" ? { required: header.required } : {}),
209+ ...(typeof header.deprecated === "boolean"
210+ ? { deprecated: header.deprecated }
211+ : {}),
212+ ...(header.schema !== undefined
213+ ? { schema: resolveLocalRef(document, header.schema) }
214+ : {}),
215+ ...(content.length > 0 ? { content } : {}),
216+ ...(asTrimmedString(header.style) ? { style: asTrimmedString(header.style) } : {}),
217+ ...(typeof header.explode === "boolean" ? { explode: header.explode } : {}),
218+ ...(directExamples.length > 0 ? { examples: directExamples } : {}),
219+ };
220+};
221+
222+const headersFromValue = (
223+ document: OpenApiJsonObject,
224+ value: unknown,
225+): ReadonlyArray<OpenApiHeader> =>
226+ Object.entries(asObject(value))
227+ .sort(([left], [right]) => left.localeCompare(right))
228+ .flatMap(([name, headerValue]) => {
229+ const header = headerFromValue(document, name, headerValue);
230+ return header ? [header] : [];
231+ });
232+
233+const serversFromValue = (value: unknown): ReadonlyArray<OpenApiServer> =>
234+ asArray(value)
235+ .map((entry) => asObject(entry))
236+ .flatMap((server) => {
237+ const url = asTrimmedString(server.url);
238+ if (!url) {
239+ return [];
240+ }
241+
242+ const variables = Object.fromEntries(
243+ Object.entries(asObject(server.variables))
244+ .sort(([left], [right]) => left.localeCompare(right))
245+ .flatMap(([name, variableValue]) => {
246+ const variableRecord = asObject(variableValue);
247+ const defaultValue = asTrimmedString(variableRecord.default);
248+ return defaultValue ? [[name, defaultValue] as const] : [];
249+ }),
250+ );
251+
252+ return [
253+ {
254+ url,
255+ ...(asTrimmedString(server.description)
256+ ? { description: asTrimmedString(server.description) }
257+ : {}),
258+ ...(Object.keys(variables).length > 0 ? { variables } : {}),
259+ },
260+ ];
261+ });
262+
263+const responseStatusRank = (statusCode: string): number => {
264+ if (/^2\\d\\d$/.test(statusCode)) {
265+ return 0;
266+ }
267+
268+ if (statusCode === "default") {
269+ return 1;
270+ }
271+
272+ return 2;
273+};
274+
275+const operationFor = (
276+ document: OpenApiJsonObject,
277+ pathTemplate: string,
278+ method: OpenApiHttpMethod,
279+): Record<string, unknown> =>
280+ asObject(asObject(asObject(document.paths)[pathTemplate])[method]);
281+
282+const pathItemFor = (
283+ document: OpenApiJsonObject,
284+ pathTemplate: string,
285+): Record<string, unknown> => asObject(asObject(document.paths)[pathTemplate]);
286+
287+const parameterKey = (input: {
288+ location: string;
289+ name: string;
290+}): string => `${input.location}:${input.name}`;
291+
292+const mergedParameterRecords = (
293+ document: OpenApiJsonObject,
294+ pathTemplate: string,
295+ method: OpenApiHttpMethod,
296+): ReadonlyMap<string, Record<string, unknown>> => {
297+ const merged = new Map<string, Record<string, unknown>>();
298+ const pathItem = pathItemFor(document, pathTemplate);
299+ const operation = operationFor(document, pathTemplate, method);
300+
301+ for (const parameterValue of asArray(pathItem.parameters)) {
302+ const parameter = asObject(resolveLocalRef(document, parameterValue));
303+ const name = asTrimmedString(parameter.name);
304+ const location = asTrimmedString(parameter.in);
305+ if (!name || !location) {
306+ continue;
307+ }
308+
309+ merged.set(parameterKey({ location, name }), parameter);
310+ }
311+
312+ for (const parameterValue of asArray(operation.parameters)) {
313+ const parameter = asObject(resolveLocalRef(document, parameterValue));
314+ const name = asTrimmedString(parameter.name);
315+ const location = asTrimmedString(parameter.in);
316+ if (!name || !location) {
317+ continue;
318+ }
319+
320+ merged.set(parameterKey({ location, name }), parameter);
321+ }
322+
323+ return merged;
324+};
325+
326+const requestBodyPayloadFor = (
327+ document: OpenApiJsonObject,
328+ pathTemplate: string,
329+ method: OpenApiHttpMethod,
330+): OpenApiToolRequestBody | null => {
331+ const operation = operationFor(document, pathTemplate, method);
332+ const requestBody = asObject(resolveLocalRef(document, operation.requestBody));
333+ if (Object.keys(requestBody).length === 0) {
334+ return null;
335+ }
336+
337+ const contents = contentEntriesFromContent(document, requestBody.content);
338+ const contentTypes = contents.map((content) => content.mediaType);
339+
340+ return {
341+ required:
342+ typeof requestBody.required === "boolean" ? requestBody.required : false,
343+ contentTypes,
344+ ...(contents.length > 0 ? { contents } : {}),
345+ };
346+};
347+
348+const responseSchemaFor = (
349+ document: OpenApiJsonObject,
350+ pathTemplate: string,
351+ method: OpenApiHttpMethod,
352+): unknown | undefined => {
353+ const operation = operationFor(document, pathTemplate, method);
354+ const responseEntries = Object.entries(asObject(operation.responses));
355+ const preferredResponses = responseEntries
356+ .filter(([status]) => /^2\\d\\d$/.test(status))
357+ .sort(([left], [right]) => left.localeCompare(right));
358+ const fallbackResponses = responseEntries.filter(([status]) => status === "default");
359+
360+ for (const [, responseValue] of [...preferredResponses, ...fallbackResponses]) {
361+ const response = resolveLocalRef(document, responseValue);
362+ const schema = contentSchemaFromOperationContent(document, asObject(response).content);
363+ if (schema !== undefined) {
364+ return schema;
365+ }
366+ }
367+
368+ return undefined;
369+};
370+
371+const responseVariantsFor = (
372+ document: OpenApiJsonObject,
373+ pathTemplate: string,
374+ method: OpenApiHttpMethod,
375+): OpenApiResponseVariant[] | undefined => {
376+ const operation = operationFor(document, pathTemplate, method);
377+ const responseEntries = Object.entries(asObject(operation.responses)).sort(
378+ ([left], [right]) =>
379+ responseStatusRank(left) - responseStatusRank(right) ||
380+ left.localeCompare(right),
381+ );
382+
383+ const responses = responseEntries.map(([statusCode, responseValue]) => {
384+ const response = asObject(resolveLocalRef(document, responseValue));
385+ const contents = contentEntriesFromContent(document, response.content);
386+ const contentTypes = contents.map((content) => content.mediaType);
387+ const preferredContent = preferredContentEntry(response.content);
388+ const examples = preferredContent
389+ ? examplesFromMediaType(preferredContent[0], preferredContent[1])
390+ : [];
391+ const headers = headersFromValue(document, response.headers);
392+
393+ return {
394+ statusCode,
395+ ...(asTrimmedString(response.description)
396+ ? { description: asTrimmedString(response.description) }
397+ : {}),
398+ contentTypes,
399+ ...(contentSchemaFromOperationContent(document, response.content) !== undefined
400+ ? { schema: contentSchemaFromOperationContent(document, response.content) }
401+ : {}),
402+ ...(examples.length > 0 ? { examples } : {}),
403+ ...(contents.length > 0 ? { contents } : {}),
404+ ...(headers.length > 0 ? { headers } : {}),
405+ };
406+ });
407+
408+ return responses.length > 0 ? responses : undefined;
409+};
410+
411+const securityRequirementFromValue = (
412+ value: unknown,
413+): OpenApiSecurityRequirement | undefined => {
414+ const requirementEntries = asArray(value);
415+ if (requirementEntries.length === 0) {
416+ return { kind: "none" };
417+ }
418+
419+ const anyOfItems = requirementEntries.flatMap((entry) => {
420+ const schemes = Object.entries(asObject(entry))
421+ .sort(([left], [right]) => left.localeCompare(right))
422+ .map(([schemeName, rawScopes]) => {
423+ const scopes = asArray(rawScopes).flatMap((scope) =>
424+ typeof scope === "string" && scope.trim().length > 0 ? [scope.trim()] : [],
425+ );
426+
427+ return {
428+ kind: "scheme" as const,
429+ schemeName,
430+ ...(scopes.length > 0 ? { scopes } : {}),
431+ };
432+ });
433+
434+ if (schemes.length === 0) {
435+ return [];
436+ }
437+
438+ return [
439+ schemes.length === 1
440+ ? schemes[0]!
441+ : {
442+ kind: "allOf" as const,
443+ items: schemes,
444+ },
445+ ];
446+ });
447+
448+ if (anyOfItems.length === 0) {
449+ return undefined;
450+ }
451+
452+ return anyOfItems.length === 1
453+ ? anyOfItems[0]
454+ : {
455+ kind: "anyOf",
456+ items: anyOfItems,
457+ };
458+};
459+
460+const authRequirementFor = (
461+ document: OpenApiJsonObject,
462+ pathTemplate: string,
463+ method: OpenApiHttpMethod,
464+): OpenApiSecurityRequirement | undefined => {
465+ const operation = operationFor(document, pathTemplate, method);
466+ if (Object.prototype.hasOwnProperty.call(operation, "security")) {
467+ return securityRequirementFromValue(operation.security);
468+ }
469+
470+ return securityRequirementFromValue(document.security);
471+};
472+
473+const collectReferencedSecuritySchemeNames = (
474+ authRequirement: OpenApiSecurityRequirement | undefined,
475+ names: Set<string>,
476+): void => {
477+ if (!authRequirement) {
478+ return;
479+ }
480+
481+ switch (authRequirement.kind) {
482+ case "none":
483+ return;
484+ case "scheme":
485+ names.add(authRequirement.schemeName);
486+ return;
487+ case "allOf":
488+ case "anyOf":
489+ for (const item of authRequirement.items) {
490+ collectReferencedSecuritySchemeNames(item, names);
491+ }
492+ }
493+};
494+
495+const oauthFlowRecord = (
496+ value: unknown,
497+):
498+ | Record<
499+ string,
500+ {
501+ authorizationUrl?: string;
502+ tokenUrl?: string;
503+ refreshUrl?: string;
504+ scopes?: Record<string, string>;
505+ }
506+ >
507+ | undefined => {
508+ const result = Object.fromEntries(
509+ Object.entries(asObject(value))
510+ .sort(([left], [right]) => left.localeCompare(right))
511+ .map(([flowName, flowValue]) => {
512+ const flowRecord = asObject(flowValue);
513+ const scopes = Object.fromEntries(
514+ Object.entries(asObject(flowRecord.scopes))
515+ .sort(([left], [right]) => left.localeCompare(right))
516+ .map(([scope, description]) => [scope, asTrimmedString(description) ?? ""]),
517+ );
518+
519+ return [
520+ flowName,
521+ {
522+ ...(asTrimmedString(flowRecord.authorizationUrl)
523+ ? { authorizationUrl: asTrimmedString(flowRecord.authorizationUrl) }
524+ : {}),
525+ ...(asTrimmedString(flowRecord.tokenUrl)
526+ ? { tokenUrl: asTrimmedString(flowRecord.tokenUrl) }
527+ : {}),
528+ ...(asTrimmedString(flowRecord.refreshUrl)
529+ ? { refreshUrl: asTrimmedString(flowRecord.refreshUrl) }
530+ : {}),
531+ ...(Object.keys(scopes).length > 0 ? { scopes } : {}),
532+ },
533+ ];
534+ }),
535+ );
536+
537+ return Object.keys(result).length > 0 ? result : undefined;
538+};
539+
540+const securitySchemesFor = (
541+ document: OpenApiJsonObject,
542+ authRequirement: OpenApiSecurityRequirement | undefined,
543+): OpenApiSecurityScheme[] | undefined => {
544+ if (!authRequirement || authRequirement.kind === "none") {
545+ return undefined;
546+ }
547+
548+ const schemeNames = new Set<string>();
549+ collectReferencedSecuritySchemeNames(authRequirement, schemeNames);
550+
551+ const securitySchemes = asObject(asObject(document.components).securitySchemes);
552+ const resolved = [...schemeNames]
553+ .sort((left, right) => left.localeCompare(right))
554+ .flatMap((schemeName) => {
555+ const rawScheme = securitySchemes[schemeName];
556+ if (rawScheme === undefined) {
557+ return [];
558+ }
559+
560+ const scheme = asObject(resolveLocalRef(document, rawScheme));
561+ const schemeType = asTrimmedString(scheme.type);
562+ if (!schemeType) {
563+ return [];
564+ }
565+
566+ const normalizedSchemeType: "apiKey" | "http" | "oauth2" | "openIdConnect" =
567+ schemeType === "apiKey" ||
568+ schemeType === "http" ||
569+ schemeType === "oauth2" ||
570+ schemeType === "openIdConnect"
571+ ? schemeType
572+ : "http";
573+
574+ const placementIn = asTrimmedString(scheme.in);
575+ const normalizedPlacementIn: "header" | "query" | "cookie" | undefined =
576+ placementIn === "header" || placementIn === "query" || placementIn === "cookie"
577+ ? placementIn
578+ : undefined;
579+
580+ return [
581+ {
582+ schemeName,
583+ schemeType: normalizedSchemeType,
584+ ...(asTrimmedString(scheme.description)
585+ ? { description: asTrimmedString(scheme.description) }
586+ : {}),
587+ ...(normalizedPlacementIn ? { placementIn: normalizedPlacementIn } : {}),
588+ ...(asTrimmedString(scheme.name)
589+ ? { placementName: asTrimmedString(scheme.name) }
590+ : {}),
591+ ...(asTrimmedString(scheme.scheme)
592+ ? { scheme: asTrimmedString(scheme.scheme) }
593+ : {}),
594+ ...(asTrimmedString(scheme.bearerFormat)
595+ ? { bearerFormat: asTrimmedString(scheme.bearerFormat) }
596+ : {}),
597+ ...(asTrimmedString(scheme.openIdConnectUrl)
598+ ? { openIdConnectUrl: asTrimmedString(scheme.openIdConnectUrl) }
599+ : {}),
600+ ...(oauthFlowRecord(scheme.flows) ? { flows: oauthFlowRecord(scheme.flows) } : {}),
601+ },
602+ ];
603+ });
604+
605+ return resolved.length > 0 ? resolved : undefined;
606+};
607+
608+const buildInputSchema = (input: {
609+ parameters: ReadonlyArray<OpenApiToolParameter>;
610+ requestBody: OpenApiToolRequestBody | null;
611+}): Record<string, unknown> | undefined => {
612+ const properties: Record<string, unknown> = {};
613+ const required: string[] = [];
614+
615+ for (const parameter of input.parameters) {
616+ const preferredContent = parameter.content?.[0]?.schema;
617+ properties[parameter.name] = preferredContent ?? { type: "string" };
618+ if (parameter.required) {
619+ required.push(parameter.name);
620+ }
621+ }
622+
623+ if (input.requestBody) {
624+ properties.body =
625+ input.requestBody.contents?.[0]?.schema ?? {
626+ type: "object",
627+ };
628+ if (input.requestBody.required) {
629+ required.push("body");
630+ }
631+ }
632+
633+ return Object.keys(properties).length > 0
634+ ? {
635+ type: "object",
636+ properties,
637+ ...(required.length > 0 ? { required } : {}),
638+ additionalProperties: false,
639+ }
640+ : undefined;
641+};
642+
643+const buildDocumentation = (input: {
644+ operation: Record<string, unknown>;
645+ parameters: ReadonlyArray<OpenApiToolParameter>;
646+ requestBody: OpenApiToolRequestBody | null;
647+ responses: ReadonlyArray<OpenApiResponseVariant> | undefined;
648+}): OpenApiToolDocumentation => {
649+ const parameterDocs = input.parameters.map((parameter) => ({
650+ name: parameter.name,
651+ location: parameter.location,
652+ required: parameter.required,
653+ ...(asTrimmedString(
654+ (parameter as unknown as Record<string, unknown>).description,
655+ )
656+ ? {
657+ description: asTrimmedString(
658+ (parameter as unknown as Record<string, unknown>).description,
659+ ),
660+ }
661+ : {}),
662+ ...(parameter.content?.[0]?.examples && parameter.content[0].examples.length > 0
663+ ? { examples: parameter.content[0].examples }
664+ : {}),
665+ }));
666+
667+ const preferredResponse =
668+ input.responses?.find((response) => /^2\\d\\d$/.test(response.statusCode)) ??
669+ input.responses?.find((response) => response.statusCode === "default") ??
670+ input.responses?.[0];
671+
672+ return {
673+ ...(asTrimmedString(input.operation.summary)
674+ ? { summary: asTrimmedString(input.operation.summary) }
675+ : {}),
676+ ...(typeof input.operation.deprecated === "boolean"
677+ ? { deprecated: input.operation.deprecated }
678+ : {}),
679+ parameters: parameterDocs,
680+ ...(input.requestBody
681+ ? {
682+ requestBody: {
683+ ...(asTrimmedString(asObject(input.operation.requestBody).description)
684+ ? {
685+ description: asTrimmedString(
686+ asObject(input.operation.requestBody).description,
687+ ),
688+ }
689+ : {}),
690+ ...(input.requestBody.contents?.[0]?.examples &&
691+ input.requestBody.contents[0].examples.length > 0
692+ ? { examples: input.requestBody.contents[0].examples }
693+ : {}),
694+ },
695+ }
696+ : {}),
697+ ...(preferredResponse
698+ ? {
699+ response: {
700+ statusCode: preferredResponse.statusCode,
701+ ...(preferredResponse.description
702+ ? { description: preferredResponse.description }
703+ : {}),
704+ contentTypes: preferredResponse.contentTypes,
705+ ...(preferredResponse.examples && preferredResponse.examples.length > 0
706+ ? { examples: preferredResponse.examples }
707+ : {}),
708+ },
709+ }
710+ : {}),
711+ };
712+};
713+
714+const extractToolParameters = (
715+ document: OpenApiJsonObject,
716+ pathTemplate: string,
717+ method: OpenApiHttpMethod,
718+): OpenApiToolParameter[] =>
719+ [...mergedParameterRecords(document, pathTemplate, method).values()].map(
720+ (parameter) => {
721+ const location = asTrimmedString(parameter.in);
722+ const name = asTrimmedString(parameter.name);
723+ if (!location || !name) {
724+ throw new Error(`Invalid OpenAPI parameter on ${method.toUpperCase()} ${pathTemplate}`);
725+ }
726+
727+ const content = contentEntriesFromContent(document, parameter.content);
728+
729+ return {
730+ name,
731+ location: location as OpenApiToolParameter["location"],
732+ required:
733+ location === "path"
734+ ? true
735+ : typeof parameter.required === "boolean"
736+ ? parameter.required
737+ : false,
738+ ...(asTrimmedString(parameter.style)
739+ ? { style: asTrimmedString(parameter.style) }
740+ : {}),
741+ ...(typeof parameter.explode === "boolean"
742+ ? { explode: parameter.explode }
743+ : {}),
744+ ...(typeof parameter.allowReserved === "boolean"
745+ ? { allowReserved: parameter.allowReserved }
746+ : {}),
747+ ...(parameter.schema !== undefined && content.length === 0
748+ ? {
749+ content: [
750+ {
751+ mediaType: "application/json",
752+ schema: resolveLocalRef(document, parameter.schema),
753+ ...(examplesFromValue(parameter).length > 0
754+ ? { examples: examplesFromValue(parameter) }
755+ : {}),
756+ },
757+ ],
758+ }
759+ : {}),
760+ ...(content.length > 0 ? { content } : {}),
761+ ...(asTrimmedString(parameter.description)
762+ ? {
763+ description: asTrimmedString(parameter.description),
764+ }
765+ : {}),
766+ } as OpenApiToolParameter;
767+ },
768+ );
769+
770+const rawToolIdForOperation = (input: {
771+ method: OpenApiHttpMethod;
772+ pathTemplate: string;
773+ operation: Record<string, unknown>;
774+}): string =>
775+ asTrimmedString(input.operation.operationId) ??
776+ (`${input.method}_${input.pathTemplate.replace(/[^a-zA-Z0-9]+/g, "_")}`.replace(
777+ /^_+|_+$/g,
778+ "",
779+ ) || `${input.method}_operation`);
780+
781+export const extractOpenApiManifest = (
782+ sourceName: string,
783+ openApiDocumentText: string,
784+): Effect.Effect<OpenApiToolManifest, Error, never> =>
785+ Effect.try({
786+ try: () => {
787+ const document = parseOpenApiDocument(openApiDocumentText);
788+ const tools: OpenApiExtractedTool[] = [];
789+ const paths = asObject(document.paths);
790+ const documentServers = serversFromValue(document.servers);
791+
792+ for (const [pathTemplate, pathItemValue] of Object.entries(paths).sort(
793+ ([left], [right]) => left.localeCompare(right),
794+ )) {
795+ const pathItem = asObject(pathItemValue);
796+ for (const method of OPEN_API_HTTP_METHODS) {
797+ const operation = asObject(pathItem[method]);
798+ if (Object.keys(operation).length === 0) {
799+ continue;
800+ }
801+
802+ const parameters = extractToolParameters(document, pathTemplate, method);
803+ const requestBody = requestBodyPayloadFor(document, pathTemplate, method);
804+ const responses = responseVariantsFor(document, pathTemplate, method);
805+ const authRequirement = authRequirementFor(document, pathTemplate, method);
806+ const servers = (() => {
807+ const operationServers = serversFromValue(operation.servers);
808+ if (operationServers.length > 0) {
809+ return operationServers;
810+ }
811+
812+ const pathServers = serversFromValue(pathItem.servers);
813+ return pathServers.length > 0 ? pathServers : undefined;
814+ })();
815+ const inputSchema = buildInputSchema({
816+ parameters,
817+ requestBody,
818+ });
819+ const outputSchema = responseSchemaFor(document, pathTemplate, method);
820+ const documentation = buildDocumentation({
821+ operation,
822+ parameters,
823+ requestBody,
824+ responses,
825+ });
826+
827+ tools.push({
828+ toolId: rawToolIdForOperation({
829+ method,
830+ pathTemplate,
831+ operation,
832+ }),
833+ ...(asTrimmedString(operation.operationId)
834+ ? { operationId: asTrimmedString(operation.operationId) }
835+ : {}),
836+ tags: asArray(operation.tags).flatMap((tag) =>
837+ typeof tag === "string" && tag.trim().length > 0 ? [tag.trim()] : [],
838+ ),
839+ name:
840+ asTrimmedString(operation.summary) ??
841+ asTrimmedString(operation.operationId) ??
842+ `${method.toUpperCase()} ${pathTemplate}`,
843+ description:
844+ asTrimmedString(operation.description) ??
845+ asTrimmedString(operation.summary) ??
846+ null,
847+ method,
848+ path: pathTemplate,
849+ invocation: {
850+ method,
851+ pathTemplate,
852+ parameters,
853+ requestBody,
854+ },
855+ operationHash: stableHash({
856+ method,
857+ path: pathTemplate,
858+ operation: stableJsonValue(operation),
859+ }),
860+ ...(inputSchema ? { inputSchema } : {}),
861+ ...(outputSchema !== undefined ? { outputSchema } : {}),
862+ documentation,
863+ ...(responses ? { responses } : {}),
864+ ...(authRequirement ? { authRequirement } : {}),
865+ ...(authRequirement
866+ ? {
867+ securitySchemes: securitySchemesFor(document, authRequirement),
868+ }
869+ : {}),
870+ ...(documentServers.length > 0 ? { documentServers } : {}),
871+ ...(servers ? { servers } : {}),
872+ });
873+ }
874+ }
875+
876+ return {
877+ version: 1,
878+ sourceHash: contentHash(openApiDocumentText),
879+ tools,
880+ };
881+ },
882+ catch: (cause) =>
883+ cause instanceof Error
884+ ? new Error(`Failed extracting OpenAPI manifest for ${sourceName}: ${cause.message}`)
885+ : new Error(String(cause)),
886+ });
plugins/openapi/sdk/http-serialization.ts+568−0
@@ -0,0 +1,568 @@
1+type OpenApiSerializableLocation = "path" | "query" | "header" | "cookie";
2+
3+export type OpenApiSerializableContent = {
4+ mediaType: string;
5+};
6+
7+export type OpenApiSerializableParameter = {
8+ name: string;
9+ location: OpenApiSerializableLocation;
10+ style?: string;
11+ explode?: boolean;
12+ allowReserved?: boolean;
13+ content?: ReadonlyArray<OpenApiSerializableContent>;
14+};
15+
16+export type OpenApiSerializableRequestBody = {
17+ contentTypes?: ReadonlyArray<string>;
18+ contents?: ReadonlyArray<OpenApiSerializableContent>;
19+};
20+
21+export type HttpBodyDecodingMode = "json" | "text" | "bytes";
22+
23+export type SerializedOpenApiRequestBody = {
24+ contentType: string;
25+ body: string | Uint8Array;
26+};
27+
28+export type SerializedOpenApiQueryEntry = {
29+ name: string;
30+ value: string;
31+ allowReserved?: boolean;
32+};
33+
34+export type SerializedOpenApiParameterValue =
35+ | {
36+ kind: "path" | "header";
37+ value: string;
38+ }
39+ | {
40+ kind: "query";
41+ entries: ReadonlyArray<SerializedOpenApiQueryEntry>;
42+ }
43+ | {
44+ kind: "cookie";
45+ pairs: ReadonlyArray<{
46+ name: string;
47+ value: string;
48+ }>;
49+ };
50+
51+const isRecord = (value: unknown): value is Record<string, unknown> =>
52+ value !== null && typeof value === "object" && !Array.isArray(value);
53+
54+const textEncoder = new TextEncoder();
55+
56+const normalizeMediaType = (value: string | undefined | null): string =>
57+ (value ?? "").split(";", 1)[0]?.trim().toLowerCase() ?? "";
58+
59+const primitiveString = (value: unknown): string => {
60+ if (typeof value === "string") {
61+ return value;
62+ }
63+
64+ if (
65+ typeof value === "number" ||
66+ typeof value === "boolean" ||
67+ typeof value === "bigint"
68+ ) {
69+ return String(value);
70+ }
71+
72+ if (value === null || value === undefined) {
73+ return "";
74+ }
75+
76+ try {
77+ return JSON.stringify(value);
78+ } catch {
79+ return String(value);
80+ }
81+};
82+
83+export const isJsonMediaType = (mediaType: string | undefined | null): boolean => {
84+ const normalized = normalizeMediaType(mediaType);
85+ return (
86+ normalized === "application/json" ||
87+ normalized.endsWith("+json") ||
88+ normalized.includes("json")
89+ );
90+};
91+
92+const isTextMediaType = (mediaType: string | undefined | null): boolean => {
93+ const normalized = normalizeMediaType(mediaType);
94+ if (normalized.length === 0) {
95+ return false;
96+ }
97+
98+ return (
99+ normalized.startsWith("text/") ||
100+ normalized === "application/xml" ||
101+ normalized.endsWith("+xml") ||
102+ normalized.endsWith("/xml") ||
103+ normalized === "application/x-www-form-urlencoded" ||
104+ normalized === "application/javascript" ||
105+ normalized === "application/ecmascript" ||
106+ normalized === "application/graphql" ||
107+ normalized === "application/sql" ||
108+ normalized === "application/x-yaml" ||
109+ normalized === "application/yaml" ||
110+ normalized === "application/toml" ||
111+ normalized === "application/csv" ||
112+ normalized === "image/svg+xml" ||
113+ normalized.endsWith("+yaml") ||
114+ normalized.endsWith("+toml")
115+ );
116+};
117+
118+export const httpBodyModeFromContentType = (
119+ mediaType: string | undefined | null,
120+): HttpBodyDecodingMode => {
121+ if (isJsonMediaType(mediaType)) {
122+ return "json";
123+ }
124+
125+ if (isTextMediaType(mediaType)) {
126+ return "text";
127+ }
128+
129+ return normalizeMediaType(mediaType).length === 0 ? "text" : "bytes";
130+};
131+
132+const objectEntries = (value: unknown): Array<[string, unknown]> =>
133+ Object.entries(isRecord(value) ? value : {}).sort(([left], [right]) =>
134+ left.localeCompare(right),
135+ );
136+
137+const defaultStyle = (location: OpenApiSerializableLocation): string => {
138+ switch (location) {
139+ case "path":
140+ case "header":
141+ return "simple";
142+ case "cookie":
143+ case "query":
144+ return "form";
145+ }
146+};
147+
148+const defaultExplode = (
149+ location: OpenApiSerializableLocation,
150+ style: string,
151+): boolean =>
152+ location === "query" || location === "cookie" ? style === "form" : false;
153+
154+const selectedStyle = (parameter: OpenApiSerializableParameter): string =>
155+ parameter.style ?? defaultStyle(parameter.location);
156+
157+const selectedExplode = (parameter: OpenApiSerializableParameter): boolean =>
158+ parameter.explode ?? defaultExplode(parameter.location, selectedStyle(parameter));
159+
160+const encodePathPart = (value: unknown): string =>
161+ encodeURIComponent(primitiveString(value));
162+
163+const encodeQueryValue = (value: string, allowReserved: boolean): string => {
164+ const encoded = encodeURIComponent(value);
165+ if (!allowReserved) {
166+ return encoded;
167+ }
168+
169+ return encoded
170+ .replace(/%3A/gi, ":")
171+ .replace(/%2F/gi, "/")
172+ .replace(/%3F/gi, "?")
173+ .replace(/%23/gi, "#")
174+ .replace(/%5B/gi, "[")
175+ .replace(/%5D/gi, "]")
176+ .replace(/%40/gi, "@")
177+ .replace(/%21/gi, "!")
178+ .replace(/%24/gi, "$")
179+ .replace(/%26/gi, "&")
180+ .replace(/%27/gi, "'")
181+ .replace(/%28/gi, "(")
182+ .replace(/%29/gi, ")")
183+ .replace(/%2A/gi, "*")
184+ .replace(/%2B/gi, "+")
185+ .replace(/%2C/gi, ",")
186+ .replace(/%3B/gi, ";")
187+ .replace(/%3D/gi, "=");
188+};
189+
190+const preferredContentType = (input: {
191+ contents?: ReadonlyArray<OpenApiSerializableContent>;
192+ contentTypes?: ReadonlyArray<string>;
193+}): string | undefined => {
194+ const candidates = [
195+ ...(input.contents ?? []).map((content) => content.mediaType),
196+ ...(input.contentTypes ?? []),
197+ ];
198+ if (candidates.length === 0) {
199+ return undefined;
200+ }
201+
202+ return (
203+ candidates.find((mediaType) => mediaType === "application/json") ??
204+ candidates.find((mediaType) => mediaType.toLowerCase().includes("+json")) ??
205+ candidates.find((mediaType) => mediaType.toLowerCase().includes("json")) ??
206+ candidates[0]
207+ );
208+};
209+
210+const serializeBinaryContentValue = (value: unknown): Uint8Array => {
211+ if (value instanceof Uint8Array) {
212+ return value;
213+ }
214+
215+ if (value instanceof ArrayBuffer) {
216+ return new Uint8Array(value);
217+ }
218+
219+ if (ArrayBuffer.isView(value)) {
220+ return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
221+ }
222+
223+ if (typeof value === "string") {
224+ return textEncoder.encode(value);
225+ }
226+
227+ if (
228+ Array.isArray(value) &&
229+ value.every(
230+ (entry) =>
231+ typeof entry === "number" &&
232+ Number.isInteger(entry) &&
233+ entry >= 0 &&
234+ entry <= 255,
235+ )
236+ ) {
237+ return Uint8Array.from(value);
238+ }
239+
240+ throw new Error(
241+ "Binary OpenAPI request bodies must be bytes, a string, or an array of byte values",
242+ );
243+};
244+
245+const serializeContentValue = (value: unknown, mediaType: string): string => {
246+ const normalized = mediaType.toLowerCase();
247+ if (
248+ normalized === "application/json" ||
249+ normalized.includes("+json") ||
250+ normalized.includes("json")
251+ ) {
252+ return JSON.stringify(value);
253+ }
254+
255+ if (normalized === "application/x-www-form-urlencoded") {
256+ const params = new URLSearchParams();
257+ for (const [key, entryValue] of objectEntries(value)) {
258+ if (Array.isArray(entryValue)) {
259+ for (const item of entryValue) {
260+ params.append(key, primitiveString(item));
261+ }
262+ continue;
263+ }
264+
265+ params.append(key, primitiveString(entryValue));
266+ }
267+ return params.toString();
268+ }
269+
270+ return primitiveString(value);
271+};
272+
273+const serializeQueryParameter = (
274+ parameter: OpenApiSerializableParameter,
275+ value: unknown,
276+): SerializedOpenApiParameterValue => {
277+ const style = selectedStyle(parameter);
278+ const explode = selectedExplode(parameter);
279+ const allowReserved = parameter.allowReserved === true;
280+ const contentType = preferredContentType({ contents: parameter.content });
281+
282+ if (contentType) {
283+ return {
284+ kind: "query",
285+ entries: [
286+ {
287+ name: parameter.name,
288+ value: serializeContentValue(value, contentType),
289+ allowReserved,
290+ },
291+ ],
292+ };
293+ }
294+
295+ if (Array.isArray(value)) {
296+ if (style === "spaceDelimited") {
297+ return {
298+ kind: "query",
299+ entries: [
300+ {
301+ name: parameter.name,
302+ value: value.map((entry) => primitiveString(entry)).join(" "),
303+ allowReserved,
304+ },
305+ ],
306+ };
307+ }
308+
309+ if (style === "pipeDelimited") {
310+ return {
311+ kind: "query",
312+ entries: [
313+ {
314+ name: parameter.name,
315+ value: value.map((entry) => primitiveString(entry)).join("|"),
316+ allowReserved,
317+ },
318+ ],
319+ };
320+ }
321+
322+ return {
323+ kind: "query",
324+ entries: explode
325+ ? value.map((entry) => ({
326+ name: parameter.name,
327+ value: primitiveString(entry),
328+ allowReserved,
329+ }))
330+ : [
331+ {
332+ name: parameter.name,
333+ value: value.map((entry) => primitiveString(entry)).join(","),
334+ allowReserved,
335+ },
336+ ],
337+ };
338+ }
339+
340+ if (isRecord(value)) {
341+ const entries = objectEntries(value);
342+
343+ if (style === "deepObject") {
344+ return {
345+ kind: "query",
346+ entries: entries.map(([key, entryValue]) => ({
347+ name: `${parameter.name}[${key}]`,
348+ value: primitiveString(entryValue),
349+ allowReserved,
350+ })),
351+ };
352+ }
353+
354+ return {
355+ kind: "query",
356+ entries: explode
357+ ? entries.map(([key, entryValue]) => ({
358+ name: key,
359+ value: primitiveString(entryValue),
360+ allowReserved,
361+ }))
362+ : [
363+ {
364+ name: parameter.name,
365+ value: entries
366+ .flatMap(([key, entryValue]) => [key, primitiveString(entryValue)])
367+ .join(","),
368+ allowReserved,
369+ },
370+ ],
371+ };
372+ }
373+
374+ return {
375+ kind: "query",
376+ entries: [
377+ {
378+ name: parameter.name,
379+ value: primitiveString(value),
380+ allowReserved,
381+ },
382+ ],
383+ };
384+};
385+
386+const serializeHeaderParameter = (
387+ parameter: OpenApiSerializableParameter,
388+ value: unknown,
389+): SerializedOpenApiParameterValue => {
390+ if (Array.isArray(value)) {
391+ return {
392+ kind: "header",
393+ value: value.map((entry) => primitiveString(entry)).join(","),
394+ };
395+ }
396+
397+ if (isRecord(value)) {
398+ const explode = selectedExplode(parameter);
399+ return {
400+ kind: "header",
401+ value: explode
402+ ? objectEntries(value)
403+ .map(([key, entryValue]) => `${key}=${primitiveString(entryValue)}`)
404+ .join(",")
405+ : objectEntries(value)
406+ .flatMap(([key, entryValue]) => [key, primitiveString(entryValue)])
407+ .join(","),
408+ };
409+ }
410+
411+ return {
412+ kind: "header",
413+ value: primitiveString(value),
414+ };
415+};
416+
417+const serializeCookieParameter = (
418+ parameter: OpenApiSerializableParameter,
419+ value: unknown,
420+): SerializedOpenApiParameterValue => {
421+ if (Array.isArray(value)) {
422+ return {
423+ kind: "cookie",
424+ pairs: value.map((entry, index) => ({
425+ name:
426+ selectedExplode(parameter) || value.length === 1
427+ ? parameter.name
428+ : `${parameter.name}${index + 1}`,
429+ value: primitiveString(entry),
430+ })),
431+ };
432+ }
433+
434+ if (isRecord(value)) {
435+ const explode = selectedExplode(parameter);
436+ return {
437+ kind: "cookie",
438+ pairs: explode
439+ ? objectEntries(value).map(([key, entryValue]) => ({
440+ name: key,
441+ value: primitiveString(entryValue),
442+ }))
443+ : [
444+ {
445+ name: parameter.name,
446+ value: objectEntries(value)
447+ .flatMap(([key, entryValue]) => [key, primitiveString(entryValue)])
448+ .join(","),
449+ },
450+ ],
451+ };
452+ }
453+
454+ return {
455+ kind: "cookie",
456+ pairs: [
457+ {
458+ name: parameter.name,
459+ value: primitiveString(value),
460+ },
461+ ],
462+ };
463+};
464+
465+const serializePathParameter = (
466+ parameter: OpenApiSerializableParameter,
467+ value: unknown,
468+): SerializedOpenApiParameterValue => {
469+ const style = selectedStyle(parameter);
470+ const explode = selectedExplode(parameter);
471+
472+ if (Array.isArray(value)) {
473+ const separator =
474+ style === "label" ? "." : style === "matrix" ? "," : ",";
475+ return {
476+ kind: "path",
477+ value:
478+ style === "label"
479+ ? `.${value.map((entry) => encodePathPart(entry)).join(separator)}`
480+ : style === "matrix"
481+ ? `;${parameter.name}=${value
482+ .map((entry) => encodePathPart(entry))
483+ .join(separator)}`
484+ : value.map((entry) => encodePathPart(entry)).join(separator),
485+ };
486+ }
487+
488+ if (isRecord(value)) {
489+ const entries = objectEntries(value).flatMap(([key, entryValue]) => [
490+ encodePathPart(key),
491+ encodePathPart(entryValue),
492+ ]);
493+ const joined = explode ? entries.join("=") : entries.join(",");
494+ return {
495+ kind: "path",
496+ value:
497+ style === "label"
498+ ? `.${joined}`
499+ : style === "matrix"
500+ ? `;${parameter.name}=${joined}`
501+ : joined,
502+ };
503+ }
504+
505+ const encoded = encodePathPart(value);
506+ return {
507+ kind: "path",
508+ value:
509+ style === "label"
510+ ? `.${encoded}`
511+ : style === "matrix"
512+ ? `;${parameter.name}=${encoded}`
513+ : encoded,
514+ };
515+};
516+
517+export const serializeOpenApiParameterValue = (
518+ parameter: OpenApiSerializableParameter,
519+ value: unknown,
520+): SerializedOpenApiParameterValue => {
521+ switch (parameter.location) {
522+ case "path":
523+ return serializePathParameter(parameter, value);
524+ case "query":
525+ return serializeQueryParameter(parameter, value);
526+ case "header":
527+ return serializeHeaderParameter(parameter, value);
528+ case "cookie":
529+ return serializeCookieParameter(parameter, value);
530+ }
531+};
532+
533+export const serializeOpenApiRequestBody = (input: {
534+ requestBody: OpenApiSerializableRequestBody;
535+ body: unknown;
536+}): SerializedOpenApiRequestBody => {
537+ const contentType = preferredContentType({
538+ contents: input.requestBody.contents,
539+ contentTypes: input.requestBody.contentTypes,
540+ });
541+
542+ const selectedContentType = contentType ?? "application/json";
543+ if (isJsonMediaType(selectedContentType) || isTextMediaType(selectedContentType)) {
544+ return {
545+ contentType: selectedContentType,
546+ body: serializeContentValue(input.body, selectedContentType),
547+ };
548+ }
549+
550+ return {
551+ contentType: selectedContentType,
552+ body: serializeBinaryContentValue(input.body),
553+ };
554+};
555+
556+export const withSerializedQueryEntries = (
557+ url: URL,
558+ entries: ReadonlyArray<SerializedOpenApiQueryEntry>,
559+): URL => {
560+ const next = new URL(url.toString());
561+ for (const entry of entries) {
562+ next.searchParams.append(
563+ entry.name,
564+ encodeQueryValue(entry.value, entry.allowReserved === true),
565+ );
566+ }
567+ return next;
568+};
plugins/openapi/sdk/index.ts+513−13
@@ -1,5 +1,6 @@
11 import { createHash } from "node:crypto";
22 import * as Effect from "effect/Effect";
3+import * as Schema from "effect/Schema";
34
45 import {
56 createCatalogImportMetadata,
@@ -11,12 +12,35 @@ import type { · 5 unmodified lines
1112 SourcePluginRuntime,
1213 } from "@executor/platform-sdk/plugins";
1314 import {
15+ deriveOpenApiNamespace,
1416 previewOpenApiDocument,
1517 type OpenApiConnectInput,
1618 type OpenApiPreviewRequest,
1719 type OpenApiPreviewResponse,
20+ type OpenApiSourceConfigPayload,
1821 type OpenApiStoredSourceData,
22+ type OpenApiUpdateSourceInput,
1923 } from "@executor/plugin-openapi-shared";
24+import {
25+ createOpenApiCatalogFragment,
26+ openApiCatalogOperationFromDefinition,
27+} from "./catalog";
28+import {
29+ compileOpenApiToolDefinitions,
30+} from "./definitions";
31+import {
32+ extractOpenApiManifest,
33+} from "./extraction";
34+import {
35+ httpBodyModeFromContentType,
36+ serializeOpenApiParameterValue,
37+ serializeOpenApiRequestBody,
38+ withSerializedQueryEntries,
39+} from "./http-serialization";
40+import {
41+ OpenApiToolProviderDataSchema,
42+ type OpenApiToolProviderData,
43+} from "./types";
2044
2145 const stableSourceHash = (value: OpenApiStoredSourceData): string =>
2246 createHash("sha256").update(JSON.stringify(value)).digest("hex").slice(0, 24);
@@ -47,9 +71,18 @@ export type OpenApiSdk = { · 24 unmodified lines
4771 previewDocument: (
4872 input: OpenApiPreviewRequest,
4973 ) => Effect.Effect<OpenApiPreviewResponse, Error, never>;
74+ getSourceConfig: (
75+ sourceId: Source["id"],
76+ ) => Effect.Effect<OpenApiSourceConfigPayload, Error, never>;
5077 createSource: (
5178 input: OpenApiConnectInput,
5279 ) => Effect.Effect<Source, Error, never>;
80+ updateSource: (
81+ input: OpenApiUpdateSourceInput,
82+ ) => Effect.Effect<Source, Error, never>;
83+ removeSource: (
84+ sourceId: Source["id"],
85+ ) => Effect.Effect<boolean, Error, never>;
5386 };
5487
5588 export type OpenApiSdkPluginOptions = {
@@ -68,6 +101,235 @@ const createStoredSourceData = ( · 12 unmodified lines
68101 lastSyncAt: null,
69102 });
70103
104+const configFromStoredSourceData = (
105+ source: Source,
106+ stored: OpenApiStoredSourceData,
107+): OpenApiSourceConfigPayload => ({
108+ name: source.name,
109+ specUrl: stored.specUrl,
110+ baseUrl: stored.baseUrl,
111+ auth: stored.auth,
112+});
113+
114+const decodeProviderData = Schema.decodeUnknownSync(OpenApiToolProviderDataSchema);
115+
116+const asRecord = (value: unknown): Record<string, unknown> =>
117+ typeof value === "object" && value !== null && !Array.isArray(value)
118+ ? (value as Record<string, unknown>)
119+ : {};
120+
121+type OpenApiToolArgs = Record<string, unknown>;
122+type OpenApiToolParameter = OpenApiToolProviderData["invocation"]["parameters"][number];
123+
124+const parameterContainerKeys: Record<
125+ OpenApiToolParameter["location"],
126+ Array<string>
127+> = {
128+ path: ["path", "pathParams", "params"],
129+ query: ["query", "queryParams", "params"],
130+ header: ["headers", "header"],
131+ cookie: ["cookies", "cookie"],
132+};
133+
134+const readParameterValue = (
135+ args: OpenApiToolArgs,
136+ parameter: OpenApiToolParameter,
137+): unknown => {
138+ const directValue = args[parameter.name];
139+ if (directValue !== undefined) {
140+ return directValue;
141+ }
142+
143+ for (const key of parameterContainerKeys[parameter.location]) {
144+ const container = args[key];
145+ if (
146+ typeof container !== "object" ||
147+ container === null ||
148+ Array.isArray(container)
149+ ) {
150+ continue;
151+ }
152+
153+ const nestedValue = (container as Record<string, unknown>)[parameter.name];
154+ if (nestedValue !== undefined) {
155+ return nestedValue;
156+ }
157+ }
158+
159+ return undefined;
160+};
161+
162+const replacePathTemplate = (
163+ pathTemplate: string,
164+ args: OpenApiToolArgs,
165+ payload: OpenApiToolProviderData["invocation"],
166+): string => {
167+ let resolvedPath = pathTemplate;
168+
169+ for (const parameter of payload.parameters) {
170+ if (parameter.location !== "path") {
171+ continue;
172+ }
173+
174+ const parameterValue = readParameterValue(args, parameter);
175+ if (parameterValue === undefined || parameterValue === null) {
176+ if (parameter.required) {
177+ throw new Error(`Missing required path parameter: ${parameter.name}`);
178+ }
179+ continue;
180+ }
181+
182+ const serialized = serializeOpenApiParameterValue(parameter, parameterValue);
183+ resolvedPath = resolvedPath.replaceAll(
184+ `{${parameter.name}}`,
185+ serialized.kind === "path"
186+ ? serialized.value
187+ : encodeURIComponent(String(parameterValue)),
188+ );
189+ }
190+
191+ const unresolved = [...resolvedPath.matchAll(/\{([^{}]+)\}/g)]
192+ .map((match) => match[1])
193+ .filter((value): value is string => typeof value === "string" && value.length > 0);
194+
195+ if (unresolved.length > 0) {
196+ const names = [...new Set(unresolved)].sort().join(", ");
197+ throw new Error(`Unresolved path parameters after substitution: ${names}`);
198+ }
199+
200+ return resolvedPath;
201+};
202+
203+const resolveOpenApiBaseUrl = (input: {
204+ stored: OpenApiStoredSourceData;
205+ providerData: OpenApiToolProviderData;
206+}): string => {
207+ if (input.stored.baseUrl && input.stored.baseUrl.trim().length > 0) {
208+ return new URL(input.stored.baseUrl).toString();
209+ }
210+
211+ const server =
212+ input.providerData.servers?.[0] ?? input.providerData.documentServers?.[0];
213+ if (server) {
214+ const expanded = Object.entries(server.variables ?? {}).reduce(
215+ (url, [name, value]) => url.replaceAll(`{${name}}`, value),
216+ server.url,
217+ );
218+ return new URL(expanded, input.stored.specUrl).toString();
219+ }
220+
221+ return new URL("/", input.stored.specUrl).toString();
222+};
223+
224+const resolveRequestUrl = (baseUrl: string, resolvedPath: string): URL => {
225+ try {
226+ return new URL(resolvedPath);
227+ } catch {
228+ const resolved = new URL(baseUrl);
229+ const basePath =
230+ resolved.pathname === "/"
231+ ? ""
232+ : resolved.pathname.endsWith("/")
233+ ? resolved.pathname.slice(0, -1)
234+ : resolved.pathname;
235+ const pathPart = resolvedPath.startsWith("/") ? resolvedPath : `/${resolvedPath}`;
236+
237+ resolved.pathname = `${basePath}${pathPart}`.replace(/\/{2,}/g, "/");
238+ resolved.search = "";
239+ resolved.hash = "";
240+ return resolved;
241+ }
242+};
243+
244+const responseHeadersRecord = (response: Response): Record<string, string> => {
245+ const headers: Record<string, string> = {};
246+ response.headers.forEach((value, key) => {
247+ headers[key] = value;
248+ });
249+ return headers;
250+};
251+
252+const decodeResponseBody = async (response: Response): Promise<unknown> => {
253+ if (response.status === 204) {
254+ return null;
255+ }
256+
257+ const bodyMode = httpBodyModeFromContentType(response.headers.get("content-type"));
258+ if (bodyMode === "json") {
259+ return response.json();
260+ }
261+ if (bodyMode === "bytes") {
262+ return new Uint8Array(await response.arrayBuffer());
263+ }
264+
265+ return response.text();
266+};
267+
268+const resolveBearerToken = (
269+ options: OpenApiSdkPluginOptions,
270+ stored: OpenApiStoredSourceData,
271+): Effect.Effect<string | null, Error, never> => {
272+ if (stored.auth.kind === "none") {
273+ return Effect.succeed(null);
274+ }
275+
276+ if (!options.secrets) {
277+ return Effect.fail(
278+ new Error("OpenAPI bearer auth is configured, but no secret resolver is available."),
279+ );
280+ }
281+
282+ return options.secrets.resolve({
283+ ref: stored.auth.tokenSecretRef,
284+ }).pipe(Effect.map((token) => token.trim()));
285+};
286+
287+const fetchOpenApiDocument = (
288+ options: OpenApiSdkPluginOptions,
289+ stored: OpenApiStoredSourceData,
290+): Effect.Effect<{
291+ text: string;
292+ etag: string | null;
293+}, Error, never> =>
294+ Effect.gen(function* () {
295+ const bearerToken = yield* resolveBearerToken(options, stored);
296+ const headers = new Headers();
297+
298+ for (const [key, value] of Object.entries(stored.defaultHeaders ?? {})) {
299+ headers.set(key, value);
300+ }
301+ if (bearerToken && bearerToken.length > 0) {
302+ headers.set("authorization", `Bearer ${bearerToken}`);
303+ }
304+ if (stored.etag) {
305+ headers.set("if-none-match", stored.etag);
306+ }
307+
308+ const response = yield* Effect.tryPromise({
309+ try: () =>
310+ fetch(stored.specUrl, {
311+ headers,
312+ }),
313+ catch: (cause) =>
314+ cause instanceof Error ? cause : new Error(String(cause)),
315+ });
316+
317+ if (!response.ok) {
318+ throw new Error(
319+ `Failed fetching OpenAPI spec (${response.status} ${response.statusText})`,
320+ );
321+ }
322+
323+ return {
324+ text: yield* Effect.tryPromise({
325+ try: () => response.text(),
326+ catch: (cause) =>
327+ cause instanceof Error ? cause : new Error(String(cause)),
328+ }),
329+ etag: response.headers.get("etag"),
330+ };
331+ });
332+
71333 const createOpenApiSourceRuntime = (
72334 options: OpenApiSdkPluginOptions,
73335 ): SourcePluginRuntime => ({
@@ -84,25 +346,194 @@ const createOpenApiSourceRuntime = ( · 10 unmodified lines
84346 scopeId: source.scopeId,
85347 sourceId: source.id,
86348 });
349+ if (stored === null) {
350+ return createSourceCatalogSyncResult({
351+ fragment: {
352+ version: "ir.v1.fragment",
353+ },
354+ importMetadata: {
355+ ...createCatalogImportMetadata({
356+ source,
357+ pluginKey: "openapi",
358+ }),
359+ importerVersion: "ir.v1.openapi",
360+ sourceConfigHash: "missing",
361+ },
362+ sourceHash: null,
363+ });
364+ }
87365
88− return createSourceCatalogSyncResult({
89− fragment: {
90− version: "ir.v1.fragment",
366+ const fetched = yield* fetchOpenApiDocument(options, stored);
367+ const manifest = yield* extractOpenApiManifest(source.name, fetched.text);
368+ const definitions = compileOpenApiToolDefinitions(manifest);
369+ const now = Date.now();
370+
371+ yield* options.storage.put({
372+ scopeId: source.scopeId,
373+ sourceId: source.id,
374+ value: {
375+ ...stored,
376+ etag: fetched.etag,
377+ lastSyncAt: now,
91378 },
379+ });
380+
381+ return createSourceCatalogSyncResult({
382+ fragment: createOpenApiCatalogFragment({
383+ source,
384+ documents: [
385+ {
386+ documentKind: "openapi",
387+ documentKey: stored.specUrl,
388+ contentText: fetched.text,
389+ fetchedAt: now,
390+ },
391+ ],
392+ operations: definitions.map(openApiCatalogOperationFromDefinition),
393+ }),
92394 importMetadata: {
93395 ...createCatalogImportMetadata({
94396 source,
95397 pluginKey: "openapi",
96398 }),
97399 importerVersion: "ir.v1.openapi",
98− sourceConfigHash:
99− stored === null ? "openapi" : stableSourceHash(stored),
400+ sourceConfigHash: stableSourceHash(stored),
100401 },
101− sourceHash: stored === null ? null : stableSourceHash(stored),
402+ sourceHash: manifest.sourceHash,
102403 });
103404 }),
104− invoke: () =>
105− Effect.fail(new Error("OpenAPI plugin invocation is not implemented.")),
405+ invoke: (input) =>
406+ Effect.gen(function* () {
407+ const stored = yield* options.storage.get({
408+ scopeId: input.source.scopeId,
409+ sourceId: input.source.id,
410+ });
411+ if (stored === null) {
412+ return yield* Effect.fail(
413+ new Error(`OpenAPI source storage missing for ${input.source.id}`),
414+ );
415+ }
416+
417+ const providerData = decodeProviderData(
418+ input.executable.binding,
419+ ) as OpenApiToolProviderData;
420+ const args = asRecord(input.args);
421+ const resolvedPath = replacePathTemplate(
422+ providerData.invocation.pathTemplate,
423+ args,
424+ providerData.invocation,
425+ );
426+ const headers: Record<string, string> = {
427+ ...(stored.defaultHeaders ?? {}),
428+ };
429+ const queryEntries: Array<{
430+ name: string;
431+ value: string;
432+ allowReserved?: boolean;
433+ }> = [];
434+ const cookieParts: string[] = [];
435+
436+ for (const parameter of providerData.invocation.parameters) {
437+ if (parameter.location === "path") {
438+ continue;
439+ }
440+
441+ const value = readParameterValue(args, parameter);
442+ if (value === undefined || value === null) {
443+ if (parameter.required) {
444+ throw new Error(
445+ `Missing required ${parameter.location} parameter ${parameter.name}`,
446+ );
447+ }
448+ continue;
449+ }
450+
451+ const serialized = serializeOpenApiParameterValue(parameter, value);
452+ if (serialized.kind === "query") {
453+ queryEntries.push(...serialized.entries);
454+ continue;
455+ }
456+ if (serialized.kind === "header") {
457+ headers[parameter.name] = serialized.value;
458+ continue;
459+ }
460+ if (serialized.kind === "cookie") {
461+ cookieParts.push(
462+ ...serialized.pairs.map(
463+ (pair) => `${pair.name}=${encodeURIComponent(pair.value)}`,
464+ ),
465+ );
466+ }
467+ }
468+
469+ let body: string | Uint8Array | undefined;
470+ if (providerData.invocation.requestBody) {
471+ const bodyValue = args.body ?? args.input;
472+ if (bodyValue !== undefined) {
473+ const serializedBody = serializeOpenApiRequestBody({
474+ requestBody: providerData.invocation.requestBody,
475+ body: bodyValue,
476+ });
477+ headers["content-type"] = serializedBody.contentType;
478+ body = serializedBody.body;
479+ }
480+ }
481+
482+ const bearerToken = yield* resolveBearerToken(options, stored);
483+ if (bearerToken && bearerToken.length > 0) {
484+ headers.authorization = `Bearer ${bearerToken}`;
485+ }
486+
487+ const requestUrl = resolveRequestUrl(
488+ resolveOpenApiBaseUrl({
489+ stored,
490+ providerData,
491+ }),
492+ resolvedPath,
493+ );
494+ const finalUrl = withSerializedQueryEntries(requestUrl, queryEntries);
495+
496+ const requestHeaders = new Headers(headers);
497+ if (cookieParts.length > 0) {
498+ const existingCookie = requestHeaders.get("cookie");
499+ requestHeaders.set(
500+ "cookie",
501+ existingCookie
502+ ? `${existingCookie}; ${cookieParts.join("; ")}`
503+ : cookieParts.join("; "),
504+ );
505+ }
506+
507+ const response = yield* Effect.tryPromise({
508+ try: () =>
509+ fetch(finalUrl.toString(), {
510+ method: providerData.method.toUpperCase(),
511+ headers: requestHeaders,
512+ ...(body !== undefined
513+ ? {
514+ body:
515+ typeof body === "string"
516+ ? body
517+ : new Uint8Array(body).buffer,
518+ }
519+ : {}),
520+ }),
521+ catch: (cause) =>
522+ cause instanceof Error ? cause : new Error(String(cause)),
523+ });
524+ const responseBody = yield* Effect.tryPromise({
525+ try: () => decodeResponseBody(response),
526+ catch: (cause) =>
527+ cause instanceof Error ? cause : new Error(String(cause)),
528+ });
529+
530+ return {
531+ data: response.ok ? responseBody : null,
532+ error: response.ok ? null : responseBody,
533+ headers: responseHeadersRecord(response),
534+ status: response.status,
535+ };
536+ }),
106537 });
107538
108539 export const openApiSdkPlugin = (
@@ -117,26 +548,95 @@ export const openApiSdkPlugin = ( · 8 unmodified lines
117548 catch: (cause) =>
118549 cause instanceof Error ? cause : new Error(String(cause)),
119550 }),
551+ getSourceConfig: (sourceId) =>
552+ Effect.gen(function* () {
553+ const source = yield* host.sources.get(sourceId);
554+ if (source.kind !== "openapi") {
555+ return yield* Effect.fail(
556+ new Error(`Source ${sourceId} is not an OpenAPI source.`),
557+ );
558+ }
559+
560+ const stored = yield* options.storage.get({
561+ scopeId: source.scopeId,
562+ sourceId: source.id,
563+ });
564+ if (stored === null) {
565+ return yield* Effect.fail(
566+ new Error(`OpenAPI source storage missing for ${source.id}`),
567+ );
568+ }
569+
570+ return configFromStoredSourceData(source, stored);
571+ }),
120572 createSource: (input) =>
121573 Effect.gen(function* () {
122574 const stored = createStoredSourceData(input);
123− const source = yield* host.sources.create({
575+ const createdSource = yield* host.sources.create({
124576 source: {
125577 name: input.name.trim(),
126578 kind: "openapi",
127579 status: "connected",
128580 enabled: true,
129− namespace: null,
581+ namespace: deriveOpenApiNamespace({
582+ specUrl: input.specUrl,
583+ title: input.name,
584+ }),
130585 },
131586 });
132587
133588 yield* options.storage.put({
134− scopeId: source.scopeId,
135− sourceId: source.id,
589+ scopeId: createdSource.scopeId,
590+ sourceId: createdSource.id,
136591 value: stored,
137592 });
138593
139− return source;
594+ return yield* host.sources.refreshCatalog(createdSource.id);
595+ }),
596+ updateSource: (input) =>
597+ Effect.gen(function* () {
598+ const source = yield* host.sources.get(input.sourceId as Source["id"]);
599+ if (source.kind !== "openapi") {
600+ return yield* Effect.fail(
601+ new Error(`Source ${input.sourceId} is not an OpenAPI source.`),
602+ );
603+ }
604+
605+ const nextStored = createStoredSourceData(input.config);
606+ const savedSource = yield* host.sources.save({
607+ ...source,
608+ name: input.config.name.trim(),
609+ namespace: deriveOpenApiNamespace({
610+ specUrl: input.config.specUrl,
611+ title: input.config.name,
612+ }),
613+ });
614+
615+ yield* options.storage.put({
616+ scopeId: savedSource.scopeId,
617+ sourceId: savedSource.id,
618+ value: nextStored,
619+ });
620+
621+ return yield* host.sources.refreshCatalog(savedSource.id);
622+ }),
623+ removeSource: (sourceId) =>
624+ Effect.gen(function* () {
625+ const source = yield* host.sources.get(sourceId);
626+ if (source.kind !== "openapi") {
627+ return yield* Effect.fail(
628+ new Error(`Source ${sourceId} is not an OpenAPI source.`),
629+ );
630+ }
631+
632+ if (options.storage.remove) {
633+ yield* options.storage.remove({
634+ scopeId: source.scopeId,
635+ sourceId: source.id,
636+ });
637+ }
638+
639+ return yield* host.sources.remove(source.id);
140640 }),
141641 }),
142642 });
plugins/openapi/sdk/package.json+5−2
@@ -6,16 +6,19 @@ · 5 unmodified lines
66 ".": "./index.ts"
77 },
88 "files": [
9− "index.ts"
9+ "*.ts"
1010 ],
1111 "scripts": {
1212 "typecheck": "bunx tsc --noEmit -p tsconfig.json"
1313 },
1414 "dependencies": {
15+ "@executor/ir": "workspace:*",
16+ "@executor/codemode-core": "workspace:*",
1517 "@executor/platform-sdk": "workspace:*",
1618 "@executor/plugin-openapi-shared": "workspace:*",
1719 "@executor/source-core": "workspace:*",
18− "effect": "catalog:"
20+ "effect": "catalog:",
21+ "yaml": "^2.8.1"
1922 },
2023 "devDependencies": {
2124 "@types/node": "catalog:",
plugins/openapi/sdk/tsconfig.json+1−3
@@ -9,7 +9,5 @@ · 8 unmodified lines
99 "lib": ["ES2022", "DOM"],
1010 "types": ["bun-types", "node"]
1111 },
12− "include": [
13− "index.ts"
14− ]
12+ "include": ["*.ts"]
1513 }
plugins/openapi/sdk/types.ts+298−0
@@ -0,0 +1,298 @@
1+import * as Schema from "effect/Schema";
2+
3+export const OPEN_API_HTTP_METHODS = [
4+ "get",
5+ "put",
6+ "post",
7+ "delete",
8+ "patch",
9+ "head",
10+ "options",
11+ "trace",
12+] as const;
13+
14+export const OPEN_API_PARAMETER_LOCATIONS = [
15+ "path",
16+ "query",
17+ "header",
18+ "cookie",
19+] as const;
20+
21+export const OpenApiHttpMethodSchema = Schema.Literal(...OPEN_API_HTTP_METHODS);
22+export const OpenApiParameterLocationSchema = Schema.Literal(
23+ ...OPEN_API_PARAMETER_LOCATIONS,
24+);
25+
26+export const OpenApiExampleSchema = Schema.Struct({
27+ valueJson: Schema.String,
28+ mediaType: Schema.optional(Schema.String),
29+ label: Schema.optional(Schema.String),
30+});
31+
32+export const OpenApiMediaContentSchema = Schema.Struct({
33+ mediaType: Schema.String,
34+ schema: Schema.optional(Schema.Unknown),
35+ examples: Schema.optional(Schema.Array(OpenApiExampleSchema)),
36+});
37+
38+export const OpenApiHeaderSchema = Schema.Struct({
39+ name: Schema.String,
40+ description: Schema.optional(Schema.String),
41+ required: Schema.optional(Schema.Boolean),
42+ deprecated: Schema.optional(Schema.Boolean),
43+ schema: Schema.optional(Schema.Unknown),
44+ content: Schema.optional(Schema.Array(OpenApiMediaContentSchema)),
45+ style: Schema.optional(Schema.String),
46+ explode: Schema.optional(Schema.Boolean),
47+ examples: Schema.optional(Schema.Array(OpenApiExampleSchema)),
48+});
49+
50+export const OpenApiToolParameterSchema = Schema.Struct({
51+ name: Schema.String,
52+ location: OpenApiParameterLocationSchema,
53+ required: Schema.Boolean,
54+ style: Schema.optional(Schema.String),
55+ explode: Schema.optional(Schema.Boolean),
56+ allowReserved: Schema.optional(Schema.Boolean),
57+ content: Schema.optional(Schema.Array(OpenApiMediaContentSchema)),
58+});
59+
60+export const OpenApiToolRequestBodySchema = Schema.Struct({
61+ required: Schema.Boolean,
62+ contentTypes: Schema.Array(Schema.String),
63+ contents: Schema.optional(Schema.Array(OpenApiMediaContentSchema)),
64+});
65+
66+export const OpenApiServerSchema = Schema.Struct({
67+ url: Schema.String,
68+ description: Schema.optional(Schema.String),
69+ variables: Schema.optional(
70+ Schema.Record({
71+ key: Schema.String,
72+ value: Schema.String,
73+ }),
74+ ),
75+});
76+
77+export const OpenApiInvocationPayloadSchema = Schema.Struct({
78+ method: OpenApiHttpMethodSchema,
79+ pathTemplate: Schema.String,
80+ parameters: Schema.Array(OpenApiToolParameterSchema),
81+ requestBody: Schema.NullOr(OpenApiToolRequestBodySchema),
82+});
83+
84+export const OpenApiParameterDocumentationSchema = Schema.Struct({
85+ name: Schema.String,
86+ location: OpenApiParameterLocationSchema,
87+ required: Schema.Boolean,
88+ description: Schema.optional(Schema.String),
89+ examples: Schema.optional(Schema.Array(OpenApiExampleSchema)),
90+});
91+
92+export const OpenApiRequestBodyDocumentationSchema = Schema.Struct({
93+ description: Schema.optional(Schema.String),
94+ examples: Schema.optional(Schema.Array(OpenApiExampleSchema)),
95+});
96+
97+export const OpenApiResponseDocumentationSchema = Schema.Struct({
98+ statusCode: Schema.String,
99+ description: Schema.optional(Schema.String),
100+ contentTypes: Schema.Array(Schema.String),
101+ examples: Schema.optional(Schema.Array(OpenApiExampleSchema)),
102+});
103+
104+export const OpenApiToolDocumentationSchema = Schema.Struct({
105+ summary: Schema.optional(Schema.String),
106+ deprecated: Schema.optional(Schema.Boolean),
107+ parameters: Schema.Array(OpenApiParameterDocumentationSchema),
108+ requestBody: Schema.optional(OpenApiRequestBodyDocumentationSchema),
109+ response: Schema.optional(OpenApiResponseDocumentationSchema),
110+});
111+
112+export type OpenApiSecurityRequirement =
113+ | {
114+ readonly kind: "none";
115+ }
116+ | {
117+ readonly kind: "scheme";
118+ readonly schemeName: string;
119+ readonly scopes?: readonly string[];
120+ }
121+ | {
122+ readonly kind: "allOf";
123+ readonly items: readonly OpenApiSecurityRequirement[];
124+ }
125+ | {
126+ readonly kind: "anyOf";
127+ readonly items: readonly OpenApiSecurityRequirement[];
128+ };
129+
130+export const OpenApiSecurityRequirementSchema = Schema.suspend(() =>
131+ Schema.Union(
132+ Schema.Struct({
133+ kind: Schema.Literal("none"),
134+ }),
135+ Schema.Struct({
136+ kind: Schema.Literal("scheme"),
137+ schemeName: Schema.String,
138+ scopes: Schema.optional(Schema.Array(Schema.String)),
139+ }),
140+ Schema.Struct({
141+ kind: Schema.Literal("allOf"),
142+ items: Schema.Array(OpenApiSecurityRequirementSchema),
143+ }),
144+ Schema.Struct({
145+ kind: Schema.Literal("anyOf"),
146+ items: Schema.Array(OpenApiSecurityRequirementSchema),
147+ }),
148+ )
149+) as Schema.Schema<OpenApiSecurityRequirement, OpenApiSecurityRequirement, never>;
150+
151+export const OpenApiSecuritySchemeTypeSchema = Schema.Literal(
152+ "apiKey",
153+ "http",
154+ "oauth2",
155+ "openIdConnect",
156+);
157+
158+export const OpenApiOAuthFlowSchema = Schema.Struct({
159+ authorizationUrl: Schema.optional(Schema.String),
160+ tokenUrl: Schema.optional(Schema.String),
161+ refreshUrl: Schema.optional(Schema.String),
162+ scopes: Schema.optional(
163+ Schema.Record({
164+ key: Schema.String,
165+ value: Schema.String,
166+ }),
167+ ),
168+});
169+
170+export const OpenApiSecuritySchemeSchema = Schema.Struct({
171+ schemeName: Schema.String,
172+ schemeType: OpenApiSecuritySchemeTypeSchema,
173+ description: Schema.optional(Schema.String),
174+ placementIn: Schema.optional(Schema.Literal("header", "query", "cookie")),
175+ placementName: Schema.optional(Schema.String),
176+ scheme: Schema.optional(Schema.String),
177+ bearerFormat: Schema.optional(Schema.String),
178+ openIdConnectUrl: Schema.optional(Schema.String),
179+ flows: Schema.optional(
180+ Schema.Record({
181+ key: Schema.String,
182+ value: OpenApiOAuthFlowSchema,
183+ }),
184+ ),
185+});
186+
187+export const OpenApiResponseVariantSchema = Schema.Struct({
188+ statusCode: Schema.String,
189+ description: Schema.optional(Schema.String),
190+ contentTypes: Schema.Array(Schema.String),
191+ schema: Schema.optional(Schema.Unknown),
192+ examples: Schema.optional(Schema.Array(OpenApiExampleSchema)),
193+ contents: Schema.optional(Schema.Array(OpenApiMediaContentSchema)),
194+ headers: Schema.optional(Schema.Array(OpenApiHeaderSchema)),
195+});
196+
197+export const OpenApiToolProviderDataSchema = Schema.Struct({
198+ kind: Schema.Literal("openapi"),
199+ toolId: Schema.String,
200+ rawToolId: Schema.String,
201+ operationId: Schema.optional(Schema.String),
202+ group: Schema.String,
203+ leaf: Schema.String,
204+ tags: Schema.Array(Schema.String),
205+ versionSegment: Schema.optional(Schema.String),
206+ method: OpenApiHttpMethodSchema,
207+ path: Schema.String,
208+ operationHash: Schema.String,
209+ invocation: OpenApiInvocationPayloadSchema,
210+ documentation: Schema.optional(OpenApiToolDocumentationSchema),
211+ responses: Schema.optional(Schema.Array(OpenApiResponseVariantSchema)),
212+ authRequirement: Schema.optional(OpenApiSecurityRequirementSchema),
213+ securitySchemes: Schema.optional(Schema.Array(OpenApiSecuritySchemeSchema)),
214+ documentServers: Schema.optional(Schema.Array(OpenApiServerSchema)),
215+ servers: Schema.optional(Schema.Array(OpenApiServerSchema)),
216+});
217+
218+export type OpenApiHttpMethod = typeof OpenApiHttpMethodSchema.Type;
219+export type OpenApiExample = typeof OpenApiExampleSchema.Type;
220+export type OpenApiMediaContent = typeof OpenApiMediaContentSchema.Type;
221+export type OpenApiHeader = typeof OpenApiHeaderSchema.Type;
222+export type OpenApiToolParameter = typeof OpenApiToolParameterSchema.Type;
223+export type OpenApiToolRequestBody = typeof OpenApiToolRequestBodySchema.Type;
224+export type OpenApiServer = typeof OpenApiServerSchema.Type;
225+export type OpenApiInvocationPayload = typeof OpenApiInvocationPayloadSchema.Type;
226+export type OpenApiParameterDocumentation =
227+ typeof OpenApiParameterDocumentationSchema.Type;
228+export type OpenApiRequestBodyDocumentation =
229+ typeof OpenApiRequestBodyDocumentationSchema.Type;
230+export type OpenApiResponseDocumentation =
231+ typeof OpenApiResponseDocumentationSchema.Type;
232+export type OpenApiToolDocumentation =
233+ typeof OpenApiToolDocumentationSchema.Type;
234+export type OpenApiSecurityScheme =
235+ typeof OpenApiSecuritySchemeSchema.Type;
236+export type OpenApiResponseVariant =
237+ typeof OpenApiResponseVariantSchema.Type;
238+export type OpenApiToolProviderData =
239+ typeof OpenApiToolProviderDataSchema.Type;
240+
241+export type OpenApiJsonPrimitive = string | number | boolean | null;
242+export type OpenApiJsonValue =
243+ | OpenApiJsonPrimitive
244+ | OpenApiJsonObject
245+ | Array<OpenApiJsonValue>;
246+export type OpenApiJsonObject = {
247+ [key: string]: OpenApiJsonValue;
248+};
249+
250+export type OpenApiExtractedTool = {
251+ toolId: string;
252+ operationId?: string;
253+ tags: readonly string[];
254+ name: string;
255+ description: string | null;
256+ method: OpenApiHttpMethod;
257+ path: string;
258+ invocation: OpenApiInvocationPayload;
259+ operationHash: string;
260+ inputSchema?: unknown;
261+ outputSchema?: unknown;
262+ documentation?: OpenApiToolDocumentation;
263+ responses?: readonly OpenApiResponseVariant[];
264+ authRequirement?: OpenApiSecurityRequirement;
265+ securitySchemes?: readonly OpenApiSecurityScheme[];
266+ documentServers?: readonly OpenApiServer[];
267+ servers?: readonly OpenApiServer[];
268+};
269+
270+export type OpenApiToolManifest = {
271+ version: 1;
272+ sourceHash: string;
273+ tools: readonly OpenApiExtractedTool[];
274+};
275+
276+export type OpenApiToolDefinition = {
277+ toolId: string;
278+ rawToolId: string;
279+ operationId?: string;
280+ name: string;
281+ description: string;
282+ group: string;
283+ leaf: string;
284+ tags: readonly string[];
285+ versionSegment?: string;
286+ method: OpenApiHttpMethod;
287+ path: string;
288+ invocation: OpenApiInvocationPayload;
289+ operationHash: string;
290+ inputSchema?: unknown;
291+ outputSchema?: unknown;
292+ documentation?: OpenApiToolDocumentation;
293+ responses?: readonly OpenApiResponseVariant[];
294+ authRequirement?: OpenApiSecurityRequirement;
295+ securitySchemes?: readonly OpenApiSecurityScheme[];
296+ documentServers?: readonly OpenApiServer[];
297+ servers?: readonly OpenApiServer[];
298+};
plugins/openapi/shared/index.ts+97−1
@@ -23,6 +23,13 @@ export const OpenApiConnectInputSchema = Schema.Struct({ · 22 unmodified lines
2323 auth: OpenApiConnectionAuthSchema,
2424 });
2525
26+export const OpenApiSourceConfigPayloadSchema = OpenApiConnectInputSchema;
27+
28+export const OpenApiUpdateSourceInputSchema = Schema.Struct({
29+ sourceId: Schema.String,
30+ config: OpenApiSourceConfigPayloadSchema,
31+});
32+
2633 export const OpenApiSourceConfigSchema = Schema.Struct({
2734 specUrl: Schema.String,
2835 baseUrl: Schema.NullOr(Schema.String),
@@ -43,20 +50,35 @@ export const OpenApiPreviewRequestSchema = Schema.Struct({ · 14 unmodified lines
4350 specUrl: Schema.String,
4451 });
4552
53+export const OpenApiPreviewSecuritySchemeSchema = Schema.Struct({
54+ name: Schema.String,
55+ kind: Schema.Literal("apiKey", "http", "oauth2", "openIdConnect", "unknown"),
56+ placement: Schema.NullOr(Schema.String),
57+ scheme: Schema.NullOr(Schema.String),
58+});
59+
4660 export const OpenApiPreviewResponseSchema = Schema.Struct({
4761 title: Schema.NullOr(Schema.String),
4862 version: Schema.NullOr(Schema.String),
4963 baseUrl: Schema.NullOr(Schema.String),
64+ namespace: Schema.NullOr(Schema.String),
5065 operationCount: Schema.Number,
66+ securitySchemes: Schema.Array(OpenApiPreviewSecuritySchemeSchema),
5167 warnings: Schema.Array(Schema.String),
5268 });
5369
5470 export type OpenApiConnectionAuth = typeof OpenApiConnectionAuthSchema.Type;
5571 export type OpenApiConnectInput = typeof OpenApiConnectInputSchema.Type;
72+export type OpenApiSourceConfigPayload =
73+ typeof OpenApiSourceConfigPayloadSchema.Type;
5674 export type OpenApiSourceConfig = typeof OpenApiSourceConfigSchema.Type;
5775 export type OpenApiStoredSourceData = typeof OpenApiStoredSourceDataSchema.Type;
5876 export type OpenApiPreviewRequest = typeof OpenApiPreviewRequestSchema.Type;
77+export type OpenApiPreviewSecurityScheme =
78+ typeof OpenApiPreviewSecuritySchemeSchema.Type;
5979 export type OpenApiPreviewResponse = typeof OpenApiPreviewResponseSchema.Type;
80+export type OpenApiUpdateSourceInput =
81+ typeof OpenApiUpdateSourceInputSchema.Type;
6082
6183 type JsonRecord = Record<string, unknown>;
6284
@@ -68,6 +90,45 @@ const asRecord = (value: unknown): JsonRecord | null => · 5 unmodified lines
6890 const asString = (value: unknown): string | null =>
6991 typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
7092
93+const namespaceFromUrl = (value: string): string | null => {
94+ try {
95+ const url = new URL(value);
96+ const hostname = url.hostname.trim().toLowerCase();
97+ if (!hostname) {
98+ return null;
99+ }
100+
101+ const parts = hostname.split(".").filter(Boolean);
102+ if (parts.length === 0) {
103+ return null;
104+ }
105+
106+ const candidate = (parts.length >= 2 ? parts[parts.length - 2] : parts[0])?.trim();
107+ return candidate && candidate.length > 0 ? candidate : null;
108+ } catch {
109+ return null;
110+ }
111+};
112+
113+const slugifyName = (value: string | null): string | null => {
114+ if (!value) {
115+ return null;
116+ }
117+
118+ const slug = value
119+ .trim()
120+ .toLowerCase()
121+ .replace(/[^a-z0-9]+/g, "-")
122+ .replace(/^-+|-+$/g, "");
123+
124+ return slug.length > 0 ? slug : null;
125+};
126+
127+export const deriveOpenApiNamespace = (input: {
128+ specUrl: string;
129+ title?: string | null;
130+}): string | null => slugifyName(input.title ?? null) ?? namespaceFromUrl(input.specUrl);
131+
71132 const parseDocument = (text: string): unknown => {
72133 const trimmed = text.trim();
73134 if (!trimmed) {
@@ -96,6 +157,35 @@ const deriveBaseUrl = (document: JsonRecord, specUrl: string): string | null => · 22 unmodified lines
96157 }
97158 };
98159
160+const previewSecuritySchemes = (document: JsonRecord): Array<OpenApiPreviewSecurityScheme> => {
161+ const components = asRecord(document.components);
162+ const securitySchemes = asRecord(components?.securitySchemes);
163+ if (!securitySchemes) {
164+ return [];
165+ }
166+
167+ return Object.entries(securitySchemes)
168+ .sort(([left], [right]) => left.localeCompare(right))
169+ .map(([name, rawScheme]) => {
170+ const scheme = asRecord(rawScheme);
171+ const rawKind = asString(scheme?.type);
172+ const kind =
173+ rawKind === "apiKey" ||
174+ rawKind === "http" ||
175+ rawKind === "oauth2" ||
176+ rawKind === "openIdConnect"
177+ ? rawKind
178+ : "unknown";
179+
180+ return {
181+ name,
182+ kind,
183+ placement: asString(scheme?.in),
184+ scheme: asString(scheme?.scheme),
185+ } satisfies OpenApiPreviewSecurityScheme;
186+ });
187+};
188+
99189 const countOperations = (document: JsonRecord): number => {
100190 const paths = asRecord(document.paths);
101191 if (paths === null) {
@@ -147,6 +237,7 @@ export const previewOpenApiDocument = async ( · 45 unmodified lines
147237
148238 const info = asRecord(document.info);
149239 const warnings: string[] = [];
240+ const title = asString(info?.title);
150241
151242 const openapiVersion = asString(document.openapi) ?? asString(document.swagger);
152243 if (openapiVersion === null) {
@@ -159,10 +250,15 @@ export const previewOpenApiDocument = async ( · 6 unmodified lines
159250 }
160251
161252 return {
162− title: asString(info?.title),
253+ title,
163254 version: asString(info?.version),
164255 baseUrl,
256+ namespace: deriveOpenApiNamespace({
257+ specUrl: input.specUrl,
258+ title,
259+ }),
165260 operationCount: countOperations(document),
261+ securitySchemes: previewSecuritySchemes(document),
166262 warnings,
167263 };
168264 };
ralph.ts+20−0
@@ -0,0 +1,20 @@
1+import { $ } from "bun";
2+
3+let prompt = `
4+You're running in a while loop with new sesions each time, you will have to check to see what the progress so far that has been made is.
5+
6+We are migrating from an old, very messy model to a plugin system for OpenAPI, GraphQL, MCP, and Google Discovery. You'll see that we've got the OpenAPI implemented. If you look at the Git diff on this branch, you'll see a bunch of deleted files and functionality around the rest of the product. We have to bring back all of the functionality that was removed for GraphQL and MCP of an API or Discovery. The thing is that it has to be now localised to those plugins, so you can read the plugin and the functionality will go away. Some examples of that are:
7+
8+- how Google Discovery has the or credentials and that or flow; that all gets implemented in the Google Discovery plugin
9+- the MCP OAuth stuff gets implemented in the MCP plugin
10+
11+Plugins can own storage; they can own API routes. They're meant to extend the product. If the product does not match what the plugins need, then we can extend the core, but the core should not be plugin-specific. What you need to do is work until this is fully migrated to this new model. Once it is fully migrated, output <STOP-TOKEN-124124> 3 times so you stop working.
12+
13+You can take notes in agent-notes/
14+`
15+let output = ''
16+
17+do {
18+ output = await $`codex exec --yolo "${prompt}"`.then(result => String(result.stdout));
19+} while (// stop token has to show up more than 1 time
20+ output.split("\n").filter(line => line.includes("<STOP-TOKEN-124124>")).length < 2);

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t