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 @@
1{1{
2 "sources": {2 "sources": {
3 "github": {3 "vercel-api": {
4 "kind": "openapi",4 "kind": "openapi",
5 "connection": {5 "name": "Vercel API"
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 }
12 }6 }
13 }7 }
14}8}
.gitignore+2−1
@@ -83,4 +83,5 @@ sources/composio-scrape/data/ · 82 unmodified lines
83personal-notes/83personal-notes/
84.tmp/84.tmp/
85executor/.env.executor-push85executor/.env.executor-push
86ignored/86ignored/
87agent-notes/
apps/web/src/source-plugins/pages.tsx+1−1
@@ -105,7 +105,7 @@ export function SourcePluginDetailPage(input: { · 104 unmodified lines
105 }105 }
106 106
107 return (107 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">
109 {definition.renderDetailPage({109 {definition.renderDetailPage({
110 source: loadedSource,110 source: loadedSource,
111 route: {111 route: {
bun.lock+4−1
@@ -146,12 +146,12 @@ · 145 unmodified lines
146 "dependencies": {146 "dependencies": {
147 "@effect-atom/atom": "0.5.3",147 "@effect-atom/atom": "0.5.3",
148 "@effect-atom/atom-react": "0.5.0",148 "@effect-atom/atom-react": "0.5.0",
149 "@effect/platform": "catalog:",
149 "@executor/platform-api": "workspace:*",150 "@executor/platform-api": "workspace:*",
150 "@executor/platform-sdk": "workspace:*",151 "@executor/platform-sdk": "workspace:*",
151 "effect": "catalog:",152 "effect": "catalog:",
152 },153 },
153 "devDependencies": {154 "devDependencies": {
154 "@effect/platform": "catalog:",
155 "@effect/platform-node": "catalog:",155 "@effect/platform-node": "catalog:",
156 "@effect/vitest": "catalog:",156 "@effect/vitest": "catalog:",
157 "@executor/effect-test-utils": "workspace:*",157 "@executor/effect-test-utils": "workspace:*",
@@ -460,10 +460,13 @@ · 302 unmodified lines
460 "name": "@executor/plugin-openapi-sdk",460 "name": "@executor/plugin-openapi-sdk",
461 "version": "0.0.0",461 "version": "0.0.0",
462 "dependencies": {462 "dependencies": {
463 "@executor/codemode-core": "workspace:*",
464 "@executor/ir": "workspace:*",
463 "@executor/platform-sdk": "workspace:*",465 "@executor/platform-sdk": "workspace:*",
464 "@executor/plugin-openapi-shared": "workspace:*",466 "@executor/plugin-openapi-shared": "workspace:*",
465 "@executor/source-core": "workspace:*",467 "@executor/source-core": "workspace:*",
466 "effect": "catalog:",468 "effect": "catalog:",
469 "yaml": "^2.8.1",
467 },470 },
468 "devDependencies": {471 "devDependencies": {
469 "@types/node": "catalog:",472 "@types/node": "catalog:",
packages/platform/sdk/src/executor-effect.ts+13−4
@@ -48,11 +48,12 @@ import { · 47 unmodified lines
48 getSourceInspectionToolDetail,48 getSourceInspectionToolDetail,
49} from "./sources/inspection";49} from "./sources/inspection";
50import {50import {
51 createManagedSource,51 createManagedSourceRecord,
52 getSource,52 getSource,
53 refreshManagedSourceCatalog,
53 listSources,54 listSources,
54 removeSource,55 removeSource,
55 saveManagedSource,56 saveManagedSourceRecord,
56} from "./sources/operations";57} from "./sources/operations";
57import type { ExecutorBackend } from "./backend";58import type { ExecutorBackend } from "./backend";
58import {59import {
@@ -288,7 +289,7 @@ export const createExecutorEffect = < · 229 unmodified lines
288 sources: {289 sources: {
289 create: ({ source }) =>290 create: ({ source }) =>
290 providePluginHostEffect(291 providePluginHostEffect(
291 createManagedSource({292 createManagedSourceRecord({
292 scopeId: executor.scopeId,293 scopeId: executor.scopeId,
293 actorScopeId: executor.actorScopeId,294 actorScopeId: executor.actorScopeId,
294 source,295 source,
@@ -304,11 +305,19 @@ export const createExecutorEffect = < · 9 unmodified lines
304 ),305 ),
305 save: (source) =>306 save: (source) =>
306 providePluginHostEffect(307 providePluginHostEffect(
307 saveManagedSource({308 saveManagedSourceRecord({
308 actorScopeId: executor.actorScopeId,309 actorScopeId: executor.actorScopeId,
309 source,310 source,
310 }),311 }),
311 ),312 ),
313 refreshCatalog: (sourceId) =>
314 providePluginHostEffect(
315 refreshManagedSourceCatalog({
316 scopeId: executor.scopeId,
317 sourceId,
318 actorScopeId: executor.actorScopeId,
319 }),
320 ),
312 remove: (sourceId) =>321 remove: (sourceId) =>
313 providePluginHostEffect(322 providePluginHostEffect(
314 removeSource({323 removeSource({
packages/platform/sdk/src/plugins.ts+3−0
@@ -34,6 +34,9 @@ export type ExecutorSdkPluginHost = { · 33 unmodified lines
34 }) => Effect.Effect<ExecutorSource, Error, never>;34 }) => Effect.Effect<ExecutorSource, Error, never>;
35 get: (sourceId: ExecutorSource["id"]) => Effect.Effect<ExecutorSource, Error, never>;35 get: (sourceId: ExecutorSource["id"]) => Effect.Effect<ExecutorSource, Error, never>;
36 save: (source: ExecutorSource) => Effect.Effect<ExecutorSource, Error, never>;36 save: (source: ExecutorSource) => Effect.Effect<ExecutorSource, Error, never>;
37 refreshCatalog: (
38 sourceId: ExecutorSource["id"],
39 ) => Effect.Effect<ExecutorSource, Error, never>;
37 remove: (sourceId: ExecutorSource["id"]) => Effect.Effect<boolean, Error, never>;40 remove: (sourceId: ExecutorSource["id"]) => Effect.Effect<boolean, Error, never>;
38 };41 };
39};42};
packages/platform/sdk/src/sources/operations.ts+114−0
@@ -156,6 +156,42 @@ export const createManagedSource = (input: { · 155 unmodified lines
156 }),156 }),
157 );157 );
158 158
159export 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
159export const getSource = (input: {195export const getSource = (input: {
160 scopeId: ScopeId;196 scopeId: ScopeId;
161 sourceId: SourceId;197 sourceId: SourceId;
@@ -224,6 +260,84 @@ export const saveManagedSource = (input: { · 62 unmodified lines
224 }),260 }),
225 );261 );
226 262
263export 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
291export 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
227export const removeSource = (input: {341export const removeSource = (input: {
228 scopeId: ScopeId;342 scopeId: ScopeId;
229 sourceId: SourceId;343 sourceId: SourceId;
plugins/openapi/http/index.ts+91−0
@@ -6,6 +6,7 @@ import { · 5 unmodified lines
6 HttpApiSchema,6 HttpApiSchema,
7} from "@effect/platform";7} from "@effect/platform";
8import {8import {
9 ControlPlaneNotFoundError,
9 ControlPlaneBadRequestError,10 ControlPlaneBadRequestError,
10 ControlPlaneForbiddenError,11 ControlPlaneForbiddenError,
11 ControlPlaneStorageError,12 ControlPlaneStorageError,
@@ -15,18 +16,24 @@ import { · 3 unmodified lines
15import { resolveRequestedLocalWorkspace } from "@executor/platform-api/local-context";16import { resolveRequestedLocalWorkspace } from "@executor/platform-api/local-context";
16import {17import {
17 ScopeIdSchema,18 ScopeIdSchema,
19 SourceIdSchema,
18 SourceSchema,20 SourceSchema,
19 type Source,21 type Source,
20} from "@executor/platform-sdk/schema";22} from "@executor/platform-sdk/schema";
21import * as Effect from "effect/Effect";23import * as Effect from "effect/Effect";
24import * as Schema from "effect/Schema";
22 25
23import {26import {
24 OpenApiConnectInputSchema,27 OpenApiConnectInputSchema,
25 OpenApiPreviewRequestSchema,28 OpenApiPreviewRequestSchema,
26 OpenApiPreviewResponseSchema,29 OpenApiPreviewResponseSchema,
30 OpenApiSourceConfigPayloadSchema,
31 OpenApiUpdateSourceInputSchema,
27 type OpenApiConnectInput,32 type OpenApiConnectInput,
28 type OpenApiPreviewRequest,33 type OpenApiPreviewRequest,
29 type OpenApiPreviewResponse,34 type OpenApiPreviewResponse,
35 type OpenApiSourceConfigPayload,
36 type OpenApiUpdateSourceInput,
30} from "@executor/plugin-openapi-shared";37} from "@executor/plugin-openapi-shared";
31 38
32type OpenApiExecutorExtension = {39type OpenApiExecutorExtension = {
@@ -37,10 +44,20 @@ type OpenApiExecutorExtension = { · 4 unmodified lines
37 createSource: (44 createSource: (
38 input: OpenApiConnectInput,45 input: OpenApiConnectInput,
39 ) => Effect.Effect<Source, Error, never>;46 ) => 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>;
40 };56 };
41};57};
42 58
43const workspaceIdParam = HttpApiSchema.param("workspaceId", ScopeIdSchema);59const workspaceIdParam = HttpApiSchema.param("workspaceId", ScopeIdSchema);
60const sourceIdParam = HttpApiSchema.param("sourceId", SourceIdSchema);
44 61
45export const OpenApiHttpGroup = HttpApiGroup.make("openapi")62export const OpenApiHttpGroup = HttpApiGroup.make("openapi")
46 .add(63 .add(
@@ -59,6 +76,31 @@ export const OpenApiHttpGroup = HttpApiGroup.make("openapi") · 12 unmodified lines
59 .addError(ControlPlaneForbiddenError)76 .addError(ControlPlaneForbiddenError)
60 .addError(ControlPlaneStorageError),77 .addError(ControlPlaneStorageError),
61 )78 )
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 )
62 .prefix("/v1");104 .prefix("/v1");
63 105
64export const OpenApiHttpApi = HttpApi.make("executor").add(OpenApiHttpGroup);106export const OpenApiHttpApi = HttpApi.make("executor").add(OpenApiHttpGroup);
@@ -82,6 +124,22 @@ const toStorageError = (operation: string) => (cause: unknown) => · 17 unmodified lines
82 details: cause instanceof Error ? cause.stack ?? cause.message : String(cause),124 details: cause instanceof Error ? cause.stack ?? cause.message : String(cause),
83 });125 });
84 126
127const 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
134const 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
85export const openApiHttpPlugin = (): ExecutorHttpPlugin<143export const openApiHttpPlugin = (): ExecutorHttpPlugin<
86 typeof OpenApiHttpGroup,144 typeof OpenApiHttpGroup,
87 OpenApiExecutorExtension145 OpenApiExecutorExtension
@@ -109,5 +167,38 @@ export const openApiHttpPlugin = (): ExecutorHttpPlugin< · 21 unmodified lines
109 Effect.mapError(toStorageError("openapi.createSource")),167 Effect.mapError(toStorageError("openapi.createSource")),
110 )168 )
111 )169 )
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 )
112 ),203 ),
113});204});
plugins/openapi/react/index.tsx+1116−125
@@ -1,4 +1,9 @@
1import type { Source } from "@executor/react";1import type {
2 Loadable,
3 Source,
4 SourceInspection,
5 SourceInspectionToolDetail,
6} from "@executor/react";
2import {7import {
3 Result,8 Result,
4 defineExecutorPluginHttpApiClient,9 defineExecutorPluginHttpApiClient,
@@ -6,17 +11,38 @@ import { · 1 unmodified line
6 useAtomSet,11 useAtomSet,
7 useExecutorMutation,12 useExecutorMutation,
8 useLocalInstallation,13 useLocalInstallation,
14 usePrefetchToolDetail,
15 useSourceDiscovery,
16 useSourceInspection,
17 useSourceToolDetail,
9} from "@executor/react";18} from "@executor/react";
19import { EmptyState, LoadableBlock } from "../../../apps/web/src/components/loadable";
20import { DocumentPanel } from "../../../apps/web/src/components/document-panel";
21import { Markdown } from "../../../apps/web/src/components/markdown";
22import {
23 IconCheck,
24 IconChevron,
25 IconClose,
26 IconCopy,
27 IconFolder,
28 IconPencil,
29 IconSearch,
30 IconTool,
31} from "../../../apps/web/src/components/icons";
32import { Badge, MethodBadge } from "../../../apps/web/src/components/ui/badge";
33import { cn } from "../../../apps/web/src/lib/utils";
10import {34import {
11 openApiHttpApiExtension,35 openApiHttpApiExtension,
12} from "@executor/plugin-openapi-http";36} from "@executor/plugin-openapi-http";
13import type {37import type {
14 OpenApiConnectInput,38 OpenApiConnectInput,
15 OpenApiPreviewRequest,39 OpenApiPreviewRequest,
40 OpenApiPreviewSecurityScheme,
16 OpenApiPreviewResponse,41 OpenApiPreviewResponse,
42 OpenApiSourceConfigPayload,
17} from "@executor/plugin-openapi-shared";43} from "@executor/plugin-openapi-shared";
18import { useNavigate } from "@tanstack/react-router";44import { useNavigate } from "@tanstack/react-router";
19import { startTransition, useState, type ReactNode } from "react";45import { startTransition, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
20 46
21type FrontendSourceTypeDefinition = {47type FrontendSourceTypeDefinition = {
22 kind: string;48 kind: string;
@@ -38,6 +64,12 @@ type FrontendPluginRegisterApi = { · 15 unmodified lines
38 };64 };
39};65};
40 66
67type RouteToolSearch = {
68 tab?: "model" | "discover";
69 tool?: string;
70 query?: string;
71};
72
41const defaultOpenApiInput = (): OpenApiConnectInput => ({73const defaultOpenApiInput = (): OpenApiConnectInput => ({
42 name: "My OpenAPI Source",74 name: "My OpenAPI Source",
43 specUrl: "https://example.com/openapi.json",75 specUrl: "https://example.com/openapi.json",
@@ -68,19 +100,108 @@ const Section = (props: { · 24 unmodified lines
68 </section>100 </section>
69);101);
70 102
71function OpenApiAddSourcePage(props: {103type ToolTreeNode = {
72 initialValue: OpenApiConnectInput;104 segment: string;
73}) {105 tool?: SourceInspectionToolDetail["summary"] | SourceInspection["tools"][number];
74 const navigate = useNavigate();106 children: Map<string, ToolTreeNode>;
107};
108
109const 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
138const 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
146const 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
160const 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
180const inputFromConfig = (
181 config: OpenApiSourceConfigPayload,
182): OpenApiConnectInput => ({
183 name: config.name,
184 specUrl: config.specUrl,
185 baseUrl: config.baseUrl,
186 auth: config.auth,
187});
188
189const useWorkspaceId = (): Source["scopeId"] => {
75 const installation = useLocalInstallation();190 const installation = useLocalInstallation();
76 const [name, setName] = useState(props.initialValue.name);191 if (installation.status === "ready") {
77 const [specUrl, setSpecUrl] = useState(props.initialValue.specUrl);192 return installation.data.scopeId;
78 const [baseUrl, setBaseUrl] = useState(props.initialValue.baseUrl ?? "");193 }
79 const [error, setError] = useState<string | null>(null);194
80 const [preview, setPreview] = useState<OpenApiPreviewResponse | null>(null);195 if (installation.status === "error") {
81 const [nameEdited, setNameEdited] = useState(false);196 throw installation.error;
82 const [baseUrlEdited, setBaseUrlEdited] = useState(false);197 }
83 const openApiHttpClient = getOpenApiHttpClient();198
199 throw new Error("Workspace is still loading.");
200};
201
202const useAvailableSecrets = (
203 openApiHttpClient: ReturnType<typeof getOpenApiHttpClient>,
204) => {
84 const secretsResult = useAtomValue(205 const secretsResult = useAtomValue(
85 openApiHttpClient.query("local", "listSecrets", {206 openApiHttpClient.query("local", "listSecrets", {
86 reactivityKeys: {207 reactivityKeys: {
@@ -89,9 +210,27 @@ function OpenApiAddSourcePage(props: { · 2 unmodified lines
89 timeToLive: "1 minute",210 timeToLive: "1 minute",
90 }),211 }),
91 );212 );
92 const availableSecrets = Result.isSuccess(secretsResult)213
93 ? secretsResult.value214 return Result.isSuccess(secretsResult) ? secretsResult.value : [];
94 : [];215};
216
217function 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 ?? "");
95 const [authKind, setAuthKind] = useState<OpenApiConnectInput["auth"]["kind"]>(234 const [authKind, setAuthKind] = useState<OpenApiConnectInput["auth"]["kind"]>(
96 props.initialValue.auth.kind,235 props.initialValue.auth.kind,
97 );236 );
@@ -100,64 +239,36 @@ function OpenApiAddSourcePage(props: { · 2 unmodified lines
100 ? props.initialValue.auth.tokenSecretRef239 ? props.initialValue.auth.tokenSecretRef
101 : "",240 : "",
102 );241 );
103 const previewDocument = useAtomSet(242 const [error, setError] = useState<string | null>(null);
104 openApiHttpClient.mutation("openapi", "previewDocument"),243 const [preview, setPreview] = useState<OpenApiPreviewResponse | null>(null);
105 { mode: "promise" },244 const [nameEdited, setNameEdited] = useState(false);
106 );245 const [baseUrlEdited, setBaseUrlEdited] = useState(false);
107 const createSource = useAtomSet(246 const [lastPreviewedSpecUrl, setLastPreviewedSpecUrl] = useState<string | null>(null);
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
124 const previewMutation = useExecutorMutation<247 const previewMutation = useExecutorMutation<
125 OpenApiPreviewRequest,248 OpenApiPreviewRequest,
126 OpenApiPreviewResponse249 OpenApiPreviewResponse
127 >(async (payload) =>250 >(async (payload) =>
128 previewDocument({251 previewDocument({
129 path: {252 path: { workspaceId },
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 },
144 payload,253 payload,
145 reactivityKeys: {
146 sources: [requireWorkspaceId()],
147 },
148 })254 })
149 );255 );
256 const submitMutation = useExecutorMutation<OpenApiConnectInput, void>(props.onSubmit);
150 257
151 const handlePreview = async () => {258 const runPreview = async (input: {
152 setError(null);259 mode: "auto" | "manual";
260 }) => {
153 const trimmedSpecUrl = specUrl.trim();261 const trimmedSpecUrl = specUrl.trim();
154 if (!trimmedSpecUrl) {262 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);
156 return;268 return;
157 }269 }
158 270
159 if (authKind === "bearer" && tokenSecretRef.trim().length === 0) {271 if (!isPreviewableSpecUrl(trimmedSpecUrl)) {
160 setError("Select a secret for bearer auth.");
161 return;272 return;
162 }273 }
163 274
@@ -168,24 +279,48 @@ function OpenApiAddSourcePage(props: { · 4 unmodified lines
168 setPreview({279 setPreview({
169 ...result,280 ...result,
170 warnings: [...result.warnings],281 warnings: [...result.warnings],
282 securitySchemes: [...result.securitySchemes],
171 });283 });
284 setLastPreviewedSpecUrl(trimmedSpecUrl);
285 if (error) {
286 setError(null);
287 }
172 288
173 if (!nameEdited && result.title) {289 if (!nameEdited && result.title) {
174 setName(result.title);290 setName(result.title);
175 }291 }
176
177 if (!baseUrlEdited && result.baseUrl) {292 if (!baseUrlEdited && result.baseUrl) {
178 setBaseUrl(result.baseUrl);293 setBaseUrl(result.baseUrl);
179 }294 }
180 } catch (cause) {295 } 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 }
182 setPreview(null);299 setPreview(null);
183 }300 }
184 };301 };
185 302
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
186 const handleSubmit = async () => {322 const handleSubmit = async () => {
187 setError(null);323 setError(null);
188
189 const trimmedName = name.trim();324 const trimmedName = name.trim();
190 const trimmedSpecUrl = specUrl.trim();325 const trimmedSpecUrl = specUrl.trim();
191 const trimmedBaseUrl = baseUrl.trim();326 const trimmedBaseUrl = baseUrl.trim();
@@ -194,14 +329,17 @@ function OpenApiAddSourcePage(props: { · 2 unmodified lines
194 setError("Name is required.");329 setError("Name is required.");
195 return;330 return;
196 }331 }
197
198 if (!trimmedSpecUrl) {332 if (!trimmedSpecUrl) {
199 setError("Spec URL is required.");333 setError("Spec URL is required.");
200 return;334 return;
201 }335 }
336 if (authKind === "bearer" && tokenSecretRef.trim().length === 0) {
337 setError("Select a secret for bearer auth.");
338 return;
339 }
202 340
203 try {341 try {
204 const source = await createMutation.mutateAsync({342 await submitMutation.mutateAsync({
205 name: trimmedName,343 name: trimmedName,
206 specUrl: trimmedSpecUrl,344 specUrl: trimmedSpecUrl,
207 baseUrl: trimmedBaseUrl || null,345 baseUrl: trimmedBaseUrl || null,
@@ -209,26 +347,14 @@ function OpenApiAddSourcePage(props: { · 1 unmodified line
209 authKind === "bearer"347 authKind === "bearer"
210 ? {348 ? {
211 kind: "bearer",349 kind: "bearer",
212 tokenSecretRef,350 tokenSecretRef: tokenSecretRef.trim(),
213 }351 }
214 : {352 : {
215 kind: "none",353 kind: "none",
216 },354 },
217 });355 });
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 });
230 } catch (cause) {356 } catch (cause) {
231 setError(cause instanceof Error ? cause.message : "Failed creating source.");357 setError(cause instanceof Error ? cause.message : "Failed saving source.");
232 }358 }
233 };359 };
234 360
@@ -258,6 +384,9 @@ function OpenApiAddSourcePage(props: { · 23 unmodified lines
258 <input384 <input
259 value={specUrl}385 value={specUrl}
260 onChange={(event) => setSpecUrl(event.target.value)}386 onChange={(event) => setSpecUrl(event.target.value)}
387 onBlur={() => {
388 void runPreview({ mode: "manual" });
389 }}
261 placeholder="https://example.com/openapi.json"390 placeholder="https://example.com/openapi.json"
262 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"391 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"
263 />392 />
@@ -318,13 +447,18 @@ function OpenApiAddSourcePage(props: { · 54 unmodified lines
318 <button447 <button
319 type="button"448 type="button"
320 onClick={() => {449 onClick={() => {
321 void handlePreview();450 void runPreview({ mode: "manual" });
322 }}451 }}
323 disabled={previewMutation.status === "pending" || createMutation.status === "pending"}452 disabled={previewMutation.status === "pending" || submitMutation.status === "pending"}
324 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"453 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"
325 >454 >
326 {previewMutation.status === "pending" ? "Previewing..." : "Preview OpenAPI Document"}455 {previewMutation.status === "pending" ? "Previewing..." : "Preview OpenAPI Document"}
327 </button>456 </button>
457 {previewMutation.status === "pending" && (
458 <div className="text-xs text-muted-foreground">
459 Inferring from spec...
460 </div>
461 )}
328 {preview && (462 {preview && (
329 <div className="text-xs text-muted-foreground">463 <div className="text-xs text-muted-foreground">
330 {preview.operationCount} operations464 {preview.operationCount} operations
@@ -347,6 +481,20 @@ function OpenApiAddSourcePage(props: { · 16 unmodified lines
347 <span className="font-medium text-foreground">Base URL:</span>{" "}481 <span className="font-medium text-foreground">Base URL:</span>{" "}
348 <span className="text-muted-foreground">{preview.baseUrl ?? "Not declared"}</span>482 <span className="text-muted-foreground">{preview.baseUrl ?? "Not declared"}</span>
349 </div>483 </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>
350 </div>498 </div>
351 {preview.warnings.length > 0 && (499 {preview.warnings.length > 0 && (
352 <div className="mt-3 rounded-md border border-amber-300/40 bg-amber-100/20 px-3 py-2 text-xs text-amber-800">500 <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
357 )}505 )}
358 </Section>506 </Section>
359 507
360 <Section title="Submit">508 <Section title={props.mode === "create" ? "Submit" : "Save"}>
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>
365 {error && (509 {error && (
366 <div className="mb-3 rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-2.5 text-[13px] text-destructive">510 <div className="mb-3 rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-2.5 text-[13px] text-destructive">
367 {error}511 {error}
@@ -373,13 +517,15 @@ function OpenApiAddSourcePage(props: { · 5 unmodified lines
373 onClick={() => {517 onClick={() => {
374 void handleSubmit();518 void handleSubmit();
375 }}519 }}
376 disabled={createMutation.status === "pending" || installation.status === "loading"}520 disabled={submitMutation.status === "pending"}
377 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"521 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"
378 >522 >
379 {createMutation.status === "pending" ? "Creating..." : "Create Source"}523 {submitMutation.status === "pending" ? props.busyLabel : props.submitLabel}
380 </button>524 </button>
381 <div className="text-xs text-muted-foreground">525 <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."}
383 </div>529 </div>
384 </div>530 </div>
385 </Section>531 </Section>
@@ -387,55 +533,900 @@ function OpenApiAddSourcePage(props: { · 1 unmodified line
387 );533 );
388}534}
389 535
536function 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
390function OpenApiEditSourcePage(props: {578function OpenApiEditSourcePage(props: {
391 source: Source;579 source: Source;
392}) {580}) {
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
393 return (619 return (
394 <Section title="OpenAPI Plugin Editor">620 <OpenApiSourceForm
395 <p className="text-sm text-muted-foreground">621 mode="edit"
396 Plugin-specific configuration is stored behind the OpenAPI plugin boundary, not on the622 initialValue={inputFromConfig(configResult.value)}
397 shared source row.623 submitLabel="Save Changes"
398 </p>624 busyLabel="Saving..."
399 <pre className="mt-3 overflow-x-auto rounded-md bg-muted/60 p-3 text-xs">625 onSubmit={async (config) => {
400 {JSON.stringify({626 const source = await updateSource({
401 id: props.source.id,627 path: {
402 kind: props.source.kind,628 workspaceId,
403 }, null, 2)}629 sourceId: props.source.id,
404 </pre>630 },
405 </Section>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 />
406 );654 );
407}655}
408 656
409function OpenApiSourceDetailPage(props: {657function OpenApiSourceDetailPage(props: {
410 source: Source;658 source: Source;
659 route: {
660 search?: unknown;
661 navigate?: unknown;
662 };
411}) {663}) {
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
412 return (747 return (
413 <div className="space-y-4">748 <LoadableBlock loadable={inspection} loading="Loading source...">
414 <Section title="Source Detail">749 {(loadedInspection) => {
415 <p className="text-sm text-muted-foreground">750 const selectedTool =
416 OpenAPI-specific configuration and auth live in plugin storage. The shared source record751 loadedInspection.tools.find((tool) => tool.path === selectedToolPath)
417 only carries generic routing and status metadata.752 ?? loadedInspection.tools[0]
418 </p>753 ?? null;
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>
428 754
429 <Section title="Plugin Boundary">755 return (
430 <p className="text-sm text-muted-foreground">756 <div className="flex h-full flex-col overflow-hidden">
431 The shell only knows there is a registered source plugin named <code>openapi</code>.757 <div className="flex h-12 shrink-0 items-center justify-between border-b border-border bg-background/95 px-4 backdrop-blur-sm">
432 Everything specific stays inside this plugin.758 <div className="flex min-w-0 items-center gap-3">
433 </p>759 <h2 className="truncate text-sm font-semibold text-foreground">
434 </Section>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
856function 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
962function 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
993function 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
1112function 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
1161function 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>
435 </div>1260 </div>
436 );1261 );
437}1262}
438 1263
1264function 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
1356function 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
1376function 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
439export const OpenApiReactPlugin = {1430export const OpenApiReactPlugin = {
440 key: "openapi",1431 key: "openapi",
441 register(api: FrontendPluginRegisterApi) {1432 register(api: FrontendPluginRegisterApi) {
@@ -448,8 +1439,8 @@ export const OpenApiReactPlugin = { · 6 unmodified lines
448 renderEditPage: ({ source }) => (1439 renderEditPage: ({ source }) => (
449 <OpenApiEditSourcePage source={source} />1440 <OpenApiEditSourcePage source={source} />
450 ),1441 ),
451 renderDetailPage: ({ source }) => (1442 renderDetailPage: ({ source, route }) => (
452 <OpenApiSourceDetailPage source={source} />1443 <OpenApiSourceDetailPage source={source} route={route} />
453 ),1444 ),
454 });1445 });
455 },1446 },
plugins/openapi/sdk/catalog.ts+919−0
@@ -0,0 +1,919 @@
1import {
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";
13import type {
14 AuthRequirement,
15 Capability,
16 ContentSpec,
17 Executable,
18 ParameterSymbol,
19 ResponseSet,
20 ResponseSymbol,
21 Scope,
22 SecuritySchemeSymbol,
23} from "@executor/ir/model";
24import {
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
47import type {
48 OpenApiHeader,
49 OpenApiMediaContent,
50 OpenApiResponseVariant,
51 OpenApiSecurityRequirement,
52 OpenApiSecurityScheme,
53 OpenApiToolProviderData,
54 OpenApiToolDefinition,
55} from "./types";
56import { openApiProviderDataFromDefinition } from "./definitions";
57
58export type OpenApiCatalogOperationInput = BaseCatalogOperationInput & {
59 providerData: OpenApiToolProviderData;
60};
61
62const 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
79const 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
115const 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
222const 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
271const 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
308const 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
380const 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
854export 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
874export 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 @@
1import type {
2 OpenApiExtractedTool,
3 OpenApiToolDefinition,
4 OpenApiToolManifest,
5 OpenApiToolProviderData,
6} from "./types";
7
8const VERSION_SEGMENT_REGEX = /^v\d+(?:[._-]\d+)?$/i;
9const IGNORED_PATH_SEGMENTS = new Set(["api"]);
10
11const 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
20const normalizeWord = (value: string): string => value.toLowerCase();
21
22const 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
34const toPascalCase = (value: string): string => {
35 const camel = toCamelCase(value);
36 return `${camel[0]?.toUpperCase() ?? ""}${camel.slice(1)}`;
37};
38
39const 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
48const pathSegmentsFromTemplate = (pathTemplate: string): Array<string> =>
49 pathTemplate
50 .split("/")
51 .map((segment) => segment.trim())
52 .filter((segment) => segment.length > 0);
53
54const isPathParameterSegment = (segment: string): boolean =>
55 segment.startsWith("{") && segment.endsWith("}");
56
57const deriveVersionSegment = (pathTemplate: string): string | undefined =>
58 pathSegmentsFromTemplate(pathTemplate)
59 .map((segment) => segment.toLowerCase())
60 .find((segment) => VERSION_SEGMENT_REGEX.test(segment));
61
62const 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
81const splitOperationIdSegments = (value: string): Array<string> =>
82 value
83 .split(/[/.]+/)
84 .map((segment) => segment.trim())
85 .filter((segment) => segment.length > 0);
86
87const 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
103const 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
121const 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
130const defaultDescription = (tool: OpenApiExtractedTool): string =>
131 tool.description ?? `${tool.method.toUpperCase()} ${tool.path}`;
132
133const 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
188export 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
228export 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 @@
1import YAML from "yaml";
2
3import type { OpenApiJsonObject } from "./types";
4
5const isOpenApiJsonObject = (value: unknown): value is OpenApiJsonObject =>
6 typeof value === "object" && value !== null && !Array.isArray(value);
7
8const parseJsonDocument = (input: string): unknown => JSON.parse(input);
9
10const parseYamlDocument = (input: string): unknown => YAML.parse(input);
11
12const parseDocument = (input: string): unknown => {
13 try {
14 return parseJsonDocument(input);
15 } catch {
16 return parseYamlDocument(input);
17 }
18};
19
20export 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 @@
1import * as Effect from "effect/Effect";
2
3import { contentHash, stableHash } from "@executor/source-core";
4
5import { parseOpenApiDocument } from "./document";
6import 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";
21import { OPEN_API_HTTP_METHODS, type OpenApiHttpMethod } from "./types";
22
23const asObject = (value: unknown): Record<string, unknown> =>
24 value !== null && typeof value === "object" && !Array.isArray(value)
25 ? (value as Record<string, unknown>)
26 : {};
27
28const asArray = (value: unknown): ReadonlyArray<unknown> =>
29 Array.isArray(value) ? value : [];
30
31const asTrimmedString = (value: unknown): string | undefined =>
32 typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
33
34const 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
50const stableJsonStringify = (value: unknown): string =>
51 JSON.stringify(stableJsonValue(value));
52
53const resolvePointerSegment = (segment: string): string =>
54 segment.replaceAll("~1", "/").replaceAll("~0", "~");
55
56const 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
93const 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
108const 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
118const 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
153const examplesFromSchema = (schema: unknown): Array<OpenApiExample> =>
154 examplesFromValue(schema);
155
156const 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
171const 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
190const 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
222const 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
233const 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
263const 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
275const operationFor = (
276 document: OpenApiJsonObject,
277 pathTemplate: string,
278 method: OpenApiHttpMethod,
279): Record<string, unknown> =>
280 asObject(asObject(asObject(document.paths)[pathTemplate])[method]);
281
282const pathItemFor = (
283 document: OpenApiJsonObject,
284 pathTemplate: string,
285): Record<string, unknown> => asObject(asObject(document.paths)[pathTemplate]);
286
287const parameterKey = (input: {
288 location: string;
289 name: string;
290}): string => `${input.location}:${input.name}`;
291
292const 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
326const 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
348const 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
371const 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
411const 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
460const 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
473const 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
495const 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
540const 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
608const 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
643const 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
714const 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
770const 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
781export 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 @@
1type OpenApiSerializableLocation = "path" | "query" | "header" | "cookie";
2
3export type OpenApiSerializableContent = {
4 mediaType: string;
5};
6
7export type OpenApiSerializableParameter = {
8 name: string;
9 location: OpenApiSerializableLocation;
10 style?: string;
11 explode?: boolean;
12 allowReserved?: boolean;
13 content?: ReadonlyArray<OpenApiSerializableContent>;
14};
15
16export type OpenApiSerializableRequestBody = {
17 contentTypes?: ReadonlyArray<string>;
18 contents?: ReadonlyArray<OpenApiSerializableContent>;
19};
20
21export type HttpBodyDecodingMode = "json" | "text" | "bytes";
22
23export type SerializedOpenApiRequestBody = {
24 contentType: string;
25 body: string | Uint8Array;
26};
27
28export type SerializedOpenApiQueryEntry = {
29 name: string;
30 value: string;
31 allowReserved?: boolean;
32};
33
34export 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
51const isRecord = (value: unknown): value is Record<string, unknown> =>
52 value !== null && typeof value === "object" && !Array.isArray(value);
53
54const textEncoder = new TextEncoder();
55
56const normalizeMediaType = (value: string | undefined | null): string =>
57 (value ?? "").split(";", 1)[0]?.trim().toLowerCase() ?? "";
58
59const 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
83export 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
92const 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
118export 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
132const objectEntries = (value: unknown): Array<[string, unknown]> =>
133 Object.entries(isRecord(value) ? value : {}).sort(([left], [right]) =>
134 left.localeCompare(right),
135 );
136
137const 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
148const defaultExplode = (
149 location: OpenApiSerializableLocation,
150 style: string,
151): boolean =>
152 location === "query" || location === "cookie" ? style === "form" : false;
153
154const selectedStyle = (parameter: OpenApiSerializableParameter): string =>
155 parameter.style ?? defaultStyle(parameter.location);
156
157const selectedExplode = (parameter: OpenApiSerializableParameter): boolean =>
158 parameter.explode ?? defaultExplode(parameter.location, selectedStyle(parameter));
159
160const encodePathPart = (value: unknown): string =>
161 encodeURIComponent(primitiveString(value));
162
163const 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
190const 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
210const 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
245const 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
273const 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
386const 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
417const 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
465const 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
517export 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
533export 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
556export 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 @@
1import { createHash } from "node:crypto";1import { createHash } from "node:crypto";
2import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
3import * as Schema from "effect/Schema";
3 4
4import {5import {
5 createCatalogImportMetadata,6 createCatalogImportMetadata,
@@ -11,12 +12,35 @@ import type { · 5 unmodified lines
11 SourcePluginRuntime,12 SourcePluginRuntime,
12} from "@executor/platform-sdk/plugins";13} from "@executor/platform-sdk/plugins";
13import {14import {
15 deriveOpenApiNamespace,
14 previewOpenApiDocument,16 previewOpenApiDocument,
15 type OpenApiConnectInput,17 type OpenApiConnectInput,
16 type OpenApiPreviewRequest,18 type OpenApiPreviewRequest,
17 type OpenApiPreviewResponse,19 type OpenApiPreviewResponse,
20 type OpenApiSourceConfigPayload,
18 type OpenApiStoredSourceData,21 type OpenApiStoredSourceData,
22 type OpenApiUpdateSourceInput,
19} from "@executor/plugin-openapi-shared";23} from "@executor/plugin-openapi-shared";
24import {
25 createOpenApiCatalogFragment,
26 openApiCatalogOperationFromDefinition,
27} from "./catalog";
28import {
29 compileOpenApiToolDefinitions,
30} from "./definitions";
31import {
32 extractOpenApiManifest,
33} from "./extraction";
34import {
35 httpBodyModeFromContentType,
36 serializeOpenApiParameterValue,
37 serializeOpenApiRequestBody,
38 withSerializedQueryEntries,
39} from "./http-serialization";
40import {
41 OpenApiToolProviderDataSchema,
42 type OpenApiToolProviderData,
43} from "./types";
20 44
21const stableSourceHash = (value: OpenApiStoredSourceData): string =>45const stableSourceHash = (value: OpenApiStoredSourceData): string =>
22 createHash("sha256").update(JSON.stringify(value)).digest("hex").slice(0, 24);46 createHash("sha256").update(JSON.stringify(value)).digest("hex").slice(0, 24);
@@ -47,9 +71,18 @@ export type OpenApiSdk = { · 24 unmodified lines
47 previewDocument: (71 previewDocument: (
48 input: OpenApiPreviewRequest,72 input: OpenApiPreviewRequest,
49 ) => Effect.Effect<OpenApiPreviewResponse, Error, never>;73 ) => Effect.Effect<OpenApiPreviewResponse, Error, never>;
74 getSourceConfig: (
75 sourceId: Source["id"],
76 ) => Effect.Effect<OpenApiSourceConfigPayload, Error, never>;
50 createSource: (77 createSource: (
51 input: OpenApiConnectInput,78 input: OpenApiConnectInput,
52 ) => Effect.Effect<Source, Error, never>;79 ) => 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>;
53};86};
54 87
55export type OpenApiSdkPluginOptions = {88export type OpenApiSdkPluginOptions = {
@@ -68,6 +101,235 @@ const createStoredSourceData = ( · 12 unmodified lines
68 lastSyncAt: null,101 lastSyncAt: null,
69});102});
70 103
104const 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
114const decodeProviderData = Schema.decodeUnknownSync(OpenApiToolProviderDataSchema);
115
116const asRecord = (value: unknown): Record<string, unknown> =>
117 typeof value === "object" && value !== null && !Array.isArray(value)
118 ? (value as Record<string, unknown>)
119 : {};
120
121type OpenApiToolArgs = Record<string, unknown>;
122type OpenApiToolParameter = OpenApiToolProviderData["invocation"]["parameters"][number];
123
124const 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
134const 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
162const 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
203const 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
224const 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
244const 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
252const 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
268const 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
287const 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
71const createOpenApiSourceRuntime = (333const createOpenApiSourceRuntime = (
72 options: OpenApiSdkPluginOptions,334 options: OpenApiSdkPluginOptions,
73): SourcePluginRuntime => ({335): SourcePluginRuntime => ({
@@ -84,25 +346,194 @@ const createOpenApiSourceRuntime = ( · 10 unmodified lines
84 scopeId: source.scopeId,346 scopeId: source.scopeId,
85 sourceId: source.id,347 sourceId: source.id,
86 });348 });
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 }
87 365
88 return createSourceCatalogSyncResult({366 const fetched = yield* fetchOpenApiDocument(options, stored);
89 fragment: {367 const manifest = yield* extractOpenApiManifest(source.name, fetched.text);
90 version: "ir.v1.fragment",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,
91 },378 },
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 }),
92 importMetadata: {394 importMetadata: {
93 ...createCatalogImportMetadata({395 ...createCatalogImportMetadata({
94 source,396 source,
95 pluginKey: "openapi",397 pluginKey: "openapi",
96 }),398 }),
97 importerVersion: "ir.v1.openapi",399 importerVersion: "ir.v1.openapi",
98 sourceConfigHash:400 sourceConfigHash: stableSourceHash(stored),
99 stored === null ? "openapi" : stableSourceHash(stored),
100 },401 },
101 sourceHash: stored === null ? null : stableSourceHash(stored),402 sourceHash: manifest.sourceHash,
102 });403 });
103 }),404 }),
104 invoke: () =>405 invoke: (input) =>
105 Effect.fail(new Error("OpenAPI plugin invocation is not implemented.")),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 }),
106});537});
107 538
108export const openApiSdkPlugin = (539export const openApiSdkPlugin = (
@@ -117,26 +548,95 @@ export const openApiSdkPlugin = ( · 8 unmodified lines
117 catch: (cause) =>548 catch: (cause) =>
118 cause instanceof Error ? cause : new Error(String(cause)),549 cause instanceof Error ? cause : new Error(String(cause)),
119 }),550 }),
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 }),
120 createSource: (input) =>572 createSource: (input) =>
121 Effect.gen(function* () {573 Effect.gen(function* () {
122 const stored = createStoredSourceData(input);574 const stored = createStoredSourceData(input);
123 const source = yield* host.sources.create({575 const createdSource = yield* host.sources.create({
124 source: {576 source: {
125 name: input.name.trim(),577 name: input.name.trim(),
126 kind: "openapi",578 kind: "openapi",
127 status: "connected",579 status: "connected",
128 enabled: true,580 enabled: true,
129 namespace: null,581 namespace: deriveOpenApiNamespace({
582 specUrl: input.specUrl,
583 title: input.name,
584 }),
130 },585 },
131 });586 });
132 587
133 yield* options.storage.put({588 yield* options.storage.put({
134 scopeId: source.scopeId,589 scopeId: createdSource.scopeId,
135 sourceId: source.id,590 sourceId: createdSource.id,
136 value: stored,591 value: stored,
137 });592 });
138 593
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);
140 }),640 }),
141 }),641 }),
142});642});
plugins/openapi/sdk/package.json+5−2
@@ -6,16 +6,19 @@ · 5 unmodified lines
6 ".": "./index.ts"6 ".": "./index.ts"
7 },7 },
8 "files": [8 "files": [
9 "index.ts"9 "*.ts"
10 ],10 ],
11 "scripts": {11 "scripts": {
12 "typecheck": "bunx tsc --noEmit -p tsconfig.json"12 "typecheck": "bunx tsc --noEmit -p tsconfig.json"
13 },13 },
14 "dependencies": {14 "dependencies": {
15 "@executor/ir": "workspace:*",
16 "@executor/codemode-core": "workspace:*",
15 "@executor/platform-sdk": "workspace:*",17 "@executor/platform-sdk": "workspace:*",
16 "@executor/plugin-openapi-shared": "workspace:*",18 "@executor/plugin-openapi-shared": "workspace:*",
17 "@executor/source-core": "workspace:*",19 "@executor/source-core": "workspace:*",
18 "effect": "catalog:"20 "effect": "catalog:",
21 "yaml": "^2.8.1"
19 },22 },
20 "devDependencies": {23 "devDependencies": {
21 "@types/node": "catalog:",24 "@types/node": "catalog:",
plugins/openapi/sdk/tsconfig.json+1−3
@@ -9,7 +9,5 @@ · 8 unmodified lines
9 "lib": ["ES2022", "DOM"],9 "lib": ["ES2022", "DOM"],
10 "types": ["bun-types", "node"]10 "types": ["bun-types", "node"]
11 },11 },
12 "include": [12 "include": ["*.ts"]
13 "index.ts"
14 ]
15}13}
plugins/openapi/sdk/types.ts+298−0
@@ -0,0 +1,298 @@
1import * as Schema from "effect/Schema";
2
3export 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
14export const OPEN_API_PARAMETER_LOCATIONS = [
15 "path",
16 "query",
17 "header",
18 "cookie",
19] as const;
20
21export const OpenApiHttpMethodSchema = Schema.Literal(...OPEN_API_HTTP_METHODS);
22export const OpenApiParameterLocationSchema = Schema.Literal(
23 ...OPEN_API_PARAMETER_LOCATIONS,
24);
25
26export const OpenApiExampleSchema = Schema.Struct({
27 valueJson: Schema.String,
28 mediaType: Schema.optional(Schema.String),
29 label: Schema.optional(Schema.String),
30});
31
32export const OpenApiMediaContentSchema = Schema.Struct({
33 mediaType: Schema.String,
34 schema: Schema.optional(Schema.Unknown),
35 examples: Schema.optional(Schema.Array(OpenApiExampleSchema)),
36});
37
38export 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
50export 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
60export const OpenApiToolRequestBodySchema = Schema.Struct({
61 required: Schema.Boolean,
62 contentTypes: Schema.Array(Schema.String),
63 contents: Schema.optional(Schema.Array(OpenApiMediaContentSchema)),
64});
65
66export 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
77export const OpenApiInvocationPayloadSchema = Schema.Struct({
78 method: OpenApiHttpMethodSchema,
79 pathTemplate: Schema.String,
80 parameters: Schema.Array(OpenApiToolParameterSchema),
81 requestBody: Schema.NullOr(OpenApiToolRequestBodySchema),
82});
83
84export 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
92export const OpenApiRequestBodyDocumentationSchema = Schema.Struct({
93 description: Schema.optional(Schema.String),
94 examples: Schema.optional(Schema.Array(OpenApiExampleSchema)),
95});
96
97export 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
104export 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
112export 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
130export 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
151export const OpenApiSecuritySchemeTypeSchema = Schema.Literal(
152 "apiKey",
153 "http",
154 "oauth2",
155 "openIdConnect",
156);
157
158export 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
170export 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
187export 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
197export 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
218export type OpenApiHttpMethod = typeof OpenApiHttpMethodSchema.Type;
219export type OpenApiExample = typeof OpenApiExampleSchema.Type;
220export type OpenApiMediaContent = typeof OpenApiMediaContentSchema.Type;
221export type OpenApiHeader = typeof OpenApiHeaderSchema.Type;
222export type OpenApiToolParameter = typeof OpenApiToolParameterSchema.Type;
223export type OpenApiToolRequestBody = typeof OpenApiToolRequestBodySchema.Type;
224export type OpenApiServer = typeof OpenApiServerSchema.Type;
225export type OpenApiInvocationPayload = typeof OpenApiInvocationPayloadSchema.Type;
226export type OpenApiParameterDocumentation =
227 typeof OpenApiParameterDocumentationSchema.Type;
228export type OpenApiRequestBodyDocumentation =
229 typeof OpenApiRequestBodyDocumentationSchema.Type;
230export type OpenApiResponseDocumentation =
231 typeof OpenApiResponseDocumentationSchema.Type;
232export type OpenApiToolDocumentation =
233 typeof OpenApiToolDocumentationSchema.Type;
234export type OpenApiSecurityScheme =
235 typeof OpenApiSecuritySchemeSchema.Type;
236export type OpenApiResponseVariant =
237 typeof OpenApiResponseVariantSchema.Type;
238export type OpenApiToolProviderData =
239 typeof OpenApiToolProviderDataSchema.Type;
240
241export type OpenApiJsonPrimitive = string | number | boolean | null;
242export type OpenApiJsonValue =
243 | OpenApiJsonPrimitive
244 | OpenApiJsonObject
245 | Array<OpenApiJsonValue>;
246export type OpenApiJsonObject = {
247 [key: string]: OpenApiJsonValue;
248};
249
250export 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
270export type OpenApiToolManifest = {
271 version: 1;
272 sourceHash: string;
273 tools: readonly OpenApiExtractedTool[];
274};
275
276export 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
23 auth: OpenApiConnectionAuthSchema,23 auth: OpenApiConnectionAuthSchema,
24});24});
25 25
26export const OpenApiSourceConfigPayloadSchema = OpenApiConnectInputSchema;
27
28export const OpenApiUpdateSourceInputSchema = Schema.Struct({
29 sourceId: Schema.String,
30 config: OpenApiSourceConfigPayloadSchema,
31});
32
26export const OpenApiSourceConfigSchema = Schema.Struct({33export const OpenApiSourceConfigSchema = Schema.Struct({
27 specUrl: Schema.String,34 specUrl: Schema.String,
28 baseUrl: Schema.NullOr(Schema.String),35 baseUrl: Schema.NullOr(Schema.String),
@@ -43,20 +50,35 @@ export const OpenApiPreviewRequestSchema = Schema.Struct({ · 14 unmodified lines
43 specUrl: Schema.String,50 specUrl: Schema.String,
44});51});
45 52
53export 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
46export const OpenApiPreviewResponseSchema = Schema.Struct({60export const OpenApiPreviewResponseSchema = Schema.Struct({
47 title: Schema.NullOr(Schema.String),61 title: Schema.NullOr(Schema.String),
48 version: Schema.NullOr(Schema.String),62 version: Schema.NullOr(Schema.String),
49 baseUrl: Schema.NullOr(Schema.String),63 baseUrl: Schema.NullOr(Schema.String),
64 namespace: Schema.NullOr(Schema.String),
50 operationCount: Schema.Number,65 operationCount: Schema.Number,
66 securitySchemes: Schema.Array(OpenApiPreviewSecuritySchemeSchema),
51 warnings: Schema.Array(Schema.String),67 warnings: Schema.Array(Schema.String),
52});68});
53 69
54export type OpenApiConnectionAuth = typeof OpenApiConnectionAuthSchema.Type;70export type OpenApiConnectionAuth = typeof OpenApiConnectionAuthSchema.Type;
55export type OpenApiConnectInput = typeof OpenApiConnectInputSchema.Type;71export type OpenApiConnectInput = typeof OpenApiConnectInputSchema.Type;
72export type OpenApiSourceConfigPayload =
73 typeof OpenApiSourceConfigPayloadSchema.Type;
56export type OpenApiSourceConfig = typeof OpenApiSourceConfigSchema.Type;74export type OpenApiSourceConfig = typeof OpenApiSourceConfigSchema.Type;
57export type OpenApiStoredSourceData = typeof OpenApiStoredSourceDataSchema.Type;75export type OpenApiStoredSourceData = typeof OpenApiStoredSourceDataSchema.Type;
58export type OpenApiPreviewRequest = typeof OpenApiPreviewRequestSchema.Type;76export type OpenApiPreviewRequest = typeof OpenApiPreviewRequestSchema.Type;
77export type OpenApiPreviewSecurityScheme =
78 typeof OpenApiPreviewSecuritySchemeSchema.Type;
59export type OpenApiPreviewResponse = typeof OpenApiPreviewResponseSchema.Type;79export type OpenApiPreviewResponse = typeof OpenApiPreviewResponseSchema.Type;
80export type OpenApiUpdateSourceInput =
81 typeof OpenApiUpdateSourceInputSchema.Type;
60 82
61type JsonRecord = Record<string, unknown>;83type JsonRecord = Record<string, unknown>;
62 84
@@ -68,6 +90,45 @@ const asRecord = (value: unknown): JsonRecord | null => · 5 unmodified lines
68const asString = (value: unknown): string | null =>90const asString = (value: unknown): string | null =>
69 typeof value === "string" && value.trim().length > 0 ? value.trim() : null;91 typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
70 92
93const 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
113const 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
127export const deriveOpenApiNamespace = (input: {
128 specUrl: string;
129 title?: string | null;
130}): string | null => slugifyName(input.title ?? null) ?? namespaceFromUrl(input.specUrl);
131
71const parseDocument = (text: string): unknown => {132const parseDocument = (text: string): unknown => {
72 const trimmed = text.trim();133 const trimmed = text.trim();
73 if (!trimmed) {134 if (!trimmed) {
@@ -96,6 +157,35 @@ const deriveBaseUrl = (document: JsonRecord, specUrl: string): string | null => · 22 unmodified lines
96 }157 }
97};158};
98 159
160const 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
99const countOperations = (document: JsonRecord): number => {189const countOperations = (document: JsonRecord): number => {
100 const paths = asRecord(document.paths);190 const paths = asRecord(document.paths);
101 if (paths === null) {191 if (paths === null) {
@@ -147,6 +237,7 @@ export const previewOpenApiDocument = async ( · 45 unmodified lines
147 237
148 const info = asRecord(document.info);238 const info = asRecord(document.info);
149 const warnings: string[] = [];239 const warnings: string[] = [];
240 const title = asString(info?.title);
150 241
151 const openapiVersion = asString(document.openapi) ?? asString(document.swagger);242 const openapiVersion = asString(document.openapi) ?? asString(document.swagger);
152 if (openapiVersion === null) {243 if (openapiVersion === null) {
@@ -159,10 +250,15 @@ export const previewOpenApiDocument = async ( · 6 unmodified lines
159 }250 }
160 251
161 return {252 return {
162 title: asString(info?.title),253 title,
163 version: asString(info?.version),254 version: asString(info?.version),
164 baseUrl,255 baseUrl,
256 namespace: deriveOpenApiNamespace({
257 specUrl: input.specUrl,
258 title,
259 }),
165 operationCount: countOperations(document),260 operationCount: countOperations(document),
261 securitySchemes: previewSecuritySchemes(document),
166 warnings,262 warnings,
167 };263 };
168};264};
ralph.ts+20−0
@@ -0,0 +1,20 @@
1import { $ } from "bun";
2
3let prompt = `
4You'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
6We 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
11Plugins 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
13You can take notes in agent-notes/
14`
15let output = ''
16
17do {
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