History

trim sizes

Rhys Sullivan committed 8 months ago

Commit 71e33a91 parent a43695c30 files · +5421 −336Browse files

Showing the changes to packages/codemode-openapi/src (10 of 30 files). Show all

Unified · Split

10 files
  1. packages/codemode-openapi/src/index.ts +26 −0
  2. packages/codemode-openapi/src/openapi-definitions.ts +19 −0
  3. packages/codemode-openapi/src/openapi-extraction.ts +701 −22
  4. packages/codemode-openapi/src/openapi-extractor-wasm/openapi_extractor_bg.wasm +0 −0
  5. packages/codemode-openapi/src/openapi-http-serialization.ts +515 −0
  6. packages/codemode-openapi/src/openapi-tool-presentation.test.ts +244 −0
  7. packages/codemode-openapi/src/openapi-tool-presentation.ts +128 −18
  8. packages/codemode-openapi/src/openapi-tools.test.ts +532 −0
  9. packages/codemode-openapi/src/openapi-tools.ts +32 −36
  10. packages/codemode-openapi/src/openapi-types.ts +136 −0
packages/codemode-openapi/src/index.ts+26−0
@@ -17,6 +17,16 @@ export { · 16 unmodified lines
1717 openApiOutputTypeSignatureFromSchema,
1818 type OpenApiToolPresentation,
1919 } from "./openapi-tool-presentation";
20+export {
21+ serializeOpenApiParameterValue,
22+ serializeOpenApiRequestBody,
23+ withSerializedQueryEntries,
24+ type OpenApiSerializableContent,
25+ type OpenApiSerializableParameter,
26+ type OpenApiSerializableRequestBody,
27+ type SerializedOpenApiParameterValue,
28+ type SerializedOpenApiQueryEntry,
29+} from "./openapi-http-serialization";
2030 export {
2131 OpenApiToolInvocationError,
2232 createOpenApiToolFromDefinition,
@@ -35,11 +45,19 @@ export { · 12 unmodified lines
3545 OpenApiToolManifestSchema,
3646 OpenApiToolParameterSchema,
3747 OpenApiToolRequestBodySchema,
48+ OpenApiMediaContentSchema,
49+ OpenApiHeaderSchema,
50+ OpenApiServerSchema,
3851 OpenApiExampleSchema,
52+ OpenApiResponseVariantSchema,
3953 OpenApiParameterDocumentationSchema,
4054 OpenApiRequestBodyDocumentationSchema,
4155 OpenApiResponseDocumentationSchema,
4256 OpenApiToolDocumentationSchema,
57+ OpenApiSecurityRequirementSchema,
58+ OpenApiSecuritySchemeTypeSchema,
59+ OpenApiOAuthFlowSchema,
60+ OpenApiSecuritySchemeSchema,
4361 OpenApiToolProviderDataSchema,
4462 OpenApiRefHintTableSchema,
4563 OpenApiRefHintValueSchema,
@@ -58,6 +76,14 @@ export { · 12 unmodified lines
5876 type OpenApiRefHintValue,
5977 type OpenApiToolParameter,
6078 type OpenApiToolRequestBody,
79+ type OpenApiMediaContent,
80+ type OpenApiHeader,
81+ type OpenApiServer,
82+ type OpenApiResponseVariant,
83+ type OpenApiSecurityRequirement,
84+ type OpenApiSecuritySchemeType,
85+ type OpenApiOAuthFlow,
86+ type OpenApiSecurityScheme,
6187 } from "./openapi-types";
6288 export type {
6389 OpenApiExample,
packages/codemode-openapi/src/openapi-definitions.ts+19−0
@@ -4,6 +4,10 @@ import { · 3 unmodified lines
44 DiscoveryTypingPayloadSchema,
55 OpenApiHttpMethodSchema,
66 OpenApiInvocationPayloadSchema,
7+ OpenApiResponseVariantSchema,
8+ OpenApiSecurityRequirementSchema,
9+ OpenApiSecuritySchemeSchema,
10+ OpenApiServerSchema,
711 OpenApiToolDocumentationSchema,
812 OpenApiToolProviderDataSchema,
913 type OpenApiExtractedTool,
@@ -143,6 +147,11 @@ export const OpenApiToolDefinitionSchema = Schema.Struct({ · 133 unmodified lines
143147 operationHash: Schema.String,
144148 typing: Schema.optional(DiscoveryTypingPayloadSchema),
145149 documentation: Schema.optional(OpenApiToolDocumentationSchema),
150+ responses: Schema.optional(Schema.Array(OpenApiResponseVariantSchema)),
151+ authRequirement: Schema.optional(OpenApiSecurityRequirementSchema),
152+ securitySchemes: Schema.optional(Schema.Array(OpenApiSecuritySchemeSchema)),
153+ documentServers: Schema.optional(Schema.Array(OpenApiServerSchema)),
154+ servers: Schema.optional(Schema.Array(OpenApiServerSchema)),
146155 });
147156
148157 export type OpenApiToolDefinition = typeof OpenApiToolDefinitionSchema.Type;
@@ -226,6 +235,11 @@ export const compileOpenApiToolDefinitions = ( · 77 unmodified lines
226235 operationHash: tool.operationHash,
227236 typing: tool.typing,
228237 documentation: tool.documentation,
238+ responses: tool.responses,
239+ authRequirement: tool.authRequirement,
240+ securitySchemes: tool.securitySchemes,
241+ documentServers: tool.documentServers,
242+ servers: tool.servers,
229243 } satisfies Omit<OpenApiToolDefinition, "toolId">;
230244 });
231245
@@ -252,4 +266,9 @@ export const openApiProviderDataFromDefinition = ( · 20 unmodified lines
252266 operationHash: definition.operationHash,
253267 invocation: definition.invocation,
254268 documentation: definition.documentation,
269+ responses: definition.responses,
270+ authRequirement: definition.authRequirement,
271+ securitySchemes: definition.securitySchemes,
272+ documentServers: definition.documentServers,
273+ servers: definition.servers,
255274 } satisfies typeof OpenApiToolProviderDataSchema.Type);
packages/codemode-openapi/src/openapi-extraction.ts+701−22
@@ -9,6 +9,9 @@ import { extractOpenApiManifestJsonWithWasm } from "./openapi-extractor-wasm"; · 8 unmodified lines
99 import {
1010 OpenApiToolManifestSchema,
1111 type OpenApiExtractedTool,
12+ type OpenApiHeader,
13+ type OpenApiSecurityRequirement,
14+ type OpenApiServer,
1215 type OpenApiJsonObject,
1316 type OpenApiSpecInput,
1417 type OpenApiToolManifest,
@@ -67,6 +70,58 @@ const asObject = (value: unknown): Record<string, unknown> => · 52 unmodified lines
6770 ? (value as Record<string, unknown>)
6871 : {};
6972
73+const asArray = (value: unknown): ReadonlyArray<unknown> =>
74+ Array.isArray(value) ? value : [];
75+
76+const asTrimmedString = (value: unknown): string | undefined =>
77+ typeof value === "string" && value.trim().length > 0
78+ ? value.trim()
79+ : undefined;
80+
81+const stableJsonValue = (value: unknown): unknown => {
82+ if (Array.isArray(value)) {
83+ return value.map((entry) => stableJsonValue(entry));
84+ }
85+
86+ if (value !== null && typeof value === "object") {
87+ return Object.fromEntries(
88+ Object.entries(value as Record<string, unknown>)
89+ .sort(([left], [right]) => left.localeCompare(right))
90+ .map(([key, entry]) => [key, stableJsonValue(entry)]),
91+ );
92+ }
93+
94+ return value;
95+};
96+
97+const stableJsonStringify = (value: unknown): string =>
98+ JSON.stringify(stableJsonValue(value));
99+
100+const collectRefKeys = (
101+ value: unknown,
102+ refs: Set<string>,
103+): void => {
104+ if (Array.isArray(value)) {
105+ for (const item of value) {
106+ collectRefKeys(item, refs);
107+ }
108+ return;
109+ }
110+
111+ if (value === null || typeof value !== "object") {
112+ return;
113+ }
114+
115+ const record = value as Record<string, unknown>;
116+ if (typeof record.$ref === "string" && record.$ref.startsWith("#/")) {
117+ refs.add(record.$ref);
118+ }
119+
120+ for (const nested of Object.values(record)) {
121+ collectRefKeys(nested, refs);
122+ }
123+};
124+
70125 const resolvePointerSegment = (segment: string): string =>
71126 segment.replaceAll("~1", "/").replaceAll("~0", "~");
72127
@@ -107,24 +162,264 @@ const resolveLocalRef = ( · 34 unmodified lines
107162 : resolvedObject;
108163 };
109164
165+const responseStatusRank = (statusCode: string): number => {
166+ if (/^2\d\d$/.test(statusCode)) {
167+ return 0;
168+ }
169+
170+ if (statusCode === "default") {
171+ return 1;
172+ }
173+
174+ return 2;
175+};
176+
177+const preferredContentEntry = (
178+ content: unknown,
179+): readonly [string, Record<string, unknown>] | undefined => {
180+ const entries = Object.entries(asObject(content))
181+ .sort(([left], [right]) => left.localeCompare(right))
182+ .map(([mediaType, value]) => [mediaType, asObject(value)] as const);
183+
184+ return entries.find(([mediaType]) => mediaType === "application/json")
185+ ?? entries.find(([mediaType]) => mediaType.toLowerCase().includes("+json"))
186+ ?? entries.find(([mediaType]) => mediaType.toLowerCase().includes("json"))
187+ ?? entries[0];
188+};
189+
110190 const contentSchemaFromOperationContent = (
111191 content: unknown,
112192 ): unknown | undefined => {
113− const entries = Object.entries(asObject(content));
114− const preferredEntry = entries.find(([mediaType]) => mediaType === "application/json")
115− ?? entries.find(([mediaType]) => mediaType.toLowerCase().includes("+json"))
116− ?? entries.find(([mediaType]) => mediaType.toLowerCase().includes("json"));
193+ return preferredContentEntry(content)?.[1].schema;
194+};
195+
196+const contentEntriesFromContent = (
197+ document: OpenApiJsonObject,
198+ content: unknown,
199+): ReadonlyArray<{
200+ mediaType: string;
201+ schema?: unknown;
202+ examples?: Array<{
203+ valueJson: string;
204+ mediaType?: string;
205+ label?: string;
206+ }>;
207+}> => {
208+ const entries = Object.entries(asObject(content))
209+ .sort(([left], [right]) => left.localeCompare(right))
210+ .map(([mediaType, mediaValue]) => {
211+ const mediaRecord = asObject(resolveLocalRef(document, mediaValue));
212+ const examples = examplesFromMediaType(mediaType, mediaRecord);
213+
214+ return {
215+ mediaType,
216+ ...(mediaRecord.schema !== undefined ? { schema: mediaRecord.schema } : {}),
217+ ...(examples.length > 0 ? { examples } : {}),
218+ };
219+ });
117220
118− return preferredEntry ? asObject(preferredEntry[1]).schema : undefined;
221+ return entries;
119222 };
120223
121−const requestBodySchemaForTool = (
224+const examplesFromValue = (
225+ value: unknown,
226+ input: {
227+ label?: string;
228+ mediaType?: string;
229+ } = {},
230+): Array<{
231+ valueJson: string;
232+ mediaType?: string;
233+ label?: string;
234+}> => {
235+ const record = asObject(value);
236+ const examples: Array<{
237+ valueJson: string;
238+ mediaType?: string;
239+ label?: string;
240+ }> = [];
241+
242+ if (record.example !== undefined) {
243+ examples.push({
244+ valueJson: stableJsonStringify(record.example),
245+ ...(input.mediaType ? { mediaType: input.mediaType } : {}),
246+ ...(input.label ? { label: input.label } : {}),
247+ });
248+ }
249+
250+ const exampleEntries = Object.entries(asObject(record.examples))
251+ .sort(([left], [right]) => left.localeCompare(right));
252+ for (const [key, exampleValue] of exampleEntries) {
253+ const exampleRecord = asObject(exampleValue);
254+ examples.push({
255+ valueJson: stableJsonStringify(
256+ exampleRecord.value !== undefined ? exampleRecord.value : exampleValue,
257+ ),
258+ ...(input.mediaType ? { mediaType: input.mediaType } : {}),
259+ label: key,
260+ });
261+ }
262+
263+ return examples;
264+};
265+
266+const examplesFromSchema = (
267+ schema: unknown,
268+): Array<{
269+ valueJson: string;
270+ mediaType?: string;
271+ label?: string;
272+}> => examplesFromValue(schema);
273+
274+const examplesFromMediaType = (
275+ mediaType: string,
276+ mediaTypeRecord: Record<string, unknown>,
277+): Array<{
278+ valueJson: string;
279+ mediaType?: string;
280+ label?: string;
281+}> => {
282+ const direct = examplesFromValue(mediaTypeRecord, { mediaType });
283+ if (direct.length > 0) {
284+ return direct;
285+ }
286+
287+ return examplesFromSchema(mediaTypeRecord.schema).map((example) => ({
288+ ...example,
289+ mediaType,
290+ }));
291+};
292+
293+const headerFromValue = (
294+ document: OpenApiJsonObject,
295+ name: string,
296+ value: unknown,
297+): OpenApiHeader | undefined => {
298+ const header = asObject(resolveLocalRef(document, value));
299+ if (Object.keys(header).length === 0) {
300+ return undefined;
301+ }
302+
303+ const content = contentEntriesFromContent(document, header.content);
304+ const directExamples =
305+ content.length > 0
306+ ? []
307+ : examplesFromValue(header);
308+
309+ return {
310+ name,
311+ ...(asTrimmedString(header.description)
312+ ? { description: asTrimmedString(header.description) }
313+ : {}),
314+ ...(typeof header.required === "boolean" ? { required: header.required } : {}),
315+ ...(typeof header.deprecated === "boolean" ? { deprecated: header.deprecated } : {}),
316+ ...(header.schema !== undefined ? { schema: header.schema } : {}),
317+ ...(content.length > 0 ? { content } : {}),
318+ ...(asTrimmedString(header.style)
319+ ? { style: asTrimmedString(header.style) }
320+ : {}),
321+ ...(typeof header.explode === "boolean" ? { explode: header.explode } : {}),
322+ ...(directExamples.length > 0 ? { examples: directExamples } : {}),
323+ };
324+};
325+
326+const headersFromValue = (
327+ document: OpenApiJsonObject,
328+ value: unknown,
329+): ReadonlyArray<OpenApiHeader> => Object.entries(asObject(value))
330+ .sort(([left], [right]) => left.localeCompare(right))
331+ .flatMap(([name, headerValue]) => {
332+ const header = headerFromValue(document, name, headerValue);
333+ return header ? [header] : [];
334+ });
335+
336+const serversFromValue = (
337+ value: unknown,
338+): ReadonlyArray<OpenApiServer> =>
339+ asArray(value)
340+ .map((entry) => asObject(entry))
341+ .flatMap((server) => {
342+ const url = asTrimmedString(server.url);
343+ if (!url) {
344+ return [];
345+ }
346+
347+ const variables = Object.fromEntries(
348+ Object.entries(asObject(server.variables))
349+ .sort(([left], [right]) => left.localeCompare(right))
350+ .flatMap(([name, variableValue]) => {
351+ const variableRecord = asObject(variableValue);
352+ const defaultValue = asTrimmedString(variableRecord.default);
353+ return defaultValue ? [[name, defaultValue] as const] : [];
354+ }),
355+ );
356+
357+ return [{
358+ url,
359+ ...(asTrimmedString(server.description)
360+ ? { description: asTrimmedString(server.description) }
361+ : {}),
362+ ...(Object.keys(variables).length > 0 ? { variables } : {}),
363+ }];
364+ });
365+
366+const pathItemForTool = (
122367 document: OpenApiJsonObject,
123368 tool: OpenApiExtractedTool,
124−): unknown | undefined => {
125− const operation = asObject(
369+): Record<string, unknown> =>
370+ asObject(asObject(document.paths)[tool.path]);
371+
372+const parameterKey = (input: {
373+ location: string;
374+ name: string;
375+}): string => `${input.location}:${input.name}`;
376+
377+const parameterRecordMapForTool = (
378+ document: OpenApiJsonObject,
379+ tool: OpenApiExtractedTool,
380+): ReadonlyMap<string, Record<string, unknown>> => {
381+ const merged = new Map<string, Record<string, unknown>>();
382+ const pathItem = pathItemForTool(document, tool);
383+ const operation = operationForTool(document, tool);
384+
385+ for (const parameterValue of asArray(pathItem.parameters)) {
386+ const parameter = asObject(resolveLocalRef(document, parameterValue));
387+ const name = asTrimmedString(parameter.name);
388+ const location = asTrimmedString(parameter.in);
389+ if (!name || !location) {
390+ continue;
391+ }
392+
393+ merged.set(parameterKey({ location, name }), parameter);
394+ }
395+
396+ for (const parameterValue of asArray(operation.parameters)) {
397+ const parameter = asObject(resolveLocalRef(document, parameterValue));
398+ const name = asTrimmedString(parameter.name);
399+ const location = asTrimmedString(parameter.in);
400+ if (!name || !location) {
401+ continue;
402+ }
403+
404+ merged.set(parameterKey({ location, name }), parameter);
405+ }
406+
407+ return merged;
408+};
409+
410+const operationForTool = (
411+ document: OpenApiJsonObject,
412+ tool: OpenApiExtractedTool,
413+): Record<string, unknown> =>
414+ asObject(
126415 asObject(asObject(document.paths)[tool.path])[tool.method],
127416 );
417+
418+const requestBodySchemaForTool = (
419+ document: OpenApiJsonObject,
420+ tool: OpenApiExtractedTool,
421+): unknown | undefined => {
422+ const operation = operationForTool(document, tool);
128423 if (Object.keys(operation).length === 0) {
129424 return undefined;
130425 }
@@ -133,13 +428,38 @@ const requestBodySchemaForTool = ( · 2 unmodified lines
133428 return contentSchemaFromOperationContent(asObject(requestBody).content);
134429 };
135430
431+const requestBodyPayloadForTool = (
432+ document: OpenApiJsonObject,
433+ tool: OpenApiExtractedTool,
434+): OpenApiExtractedTool["invocation"]["requestBody"] => {
435+ const operation = operationForTool(document, tool);
436+ if (Object.keys(operation).length === 0) {
437+ return tool.invocation.requestBody;
438+ }
439+
440+ const requestBody = asObject(resolveLocalRef(document, operation.requestBody));
441+ if (Object.keys(requestBody).length === 0) {
442+ return tool.invocation.requestBody;
443+ }
444+
445+ const contents = contentEntriesFromContent(document, requestBody.content);
446+ const contentTypes = contents.map((content) => content.mediaType);
447+
448+ return {
449+ required:
450+ typeof requestBody.required === "boolean"
451+ ? requestBody.required
452+ : (tool.invocation.requestBody?.required ?? false),
453+ contentTypes,
454+ ...(contents.length > 0 ? { contents } : {}),
455+ };
456+};
457+
136458 const responseSchemaForTool = (
137459 document: OpenApiJsonObject,
138460 tool: OpenApiExtractedTool,
139461 ): unknown | undefined => {
140− const operation = asObject(
141− asObject(asObject(document.paths)[tool.path])[tool.method],
142− );
462+ const operation = operationForTool(document, tool);
143463 if (Object.keys(operation).length === 0) {
144464 return undefined;
145465 }
@@ -161,29 +481,388 @@ const responseSchemaForTool = ( · 15 unmodified lines
161481 return undefined;
162482 };
163483
484+const responseVariantsForTool = (
485+ document: OpenApiJsonObject,
486+ tool: OpenApiExtractedTool,
487+): OpenApiExtractedTool["responses"] => {
488+ const operation = operationForTool(document, tool);
489+ if (Object.keys(operation).length === 0) {
490+ return undefined;
491+ }
492+
493+ const responseEntries = Object.entries(asObject(operation.responses))
494+ .sort(([left], [right]) =>
495+ responseStatusRank(left) - responseStatusRank(right) || left.localeCompare(right),
496+ );
497+
498+ const responses = responseEntries.map(([statusCode, responseValue]) => {
499+ const response = asObject(resolveLocalRef(document, responseValue));
500+ const contents = contentEntriesFromContent(document, response.content);
501+ const contentTypes = contents.map((content) => content.mediaType);
502+ const preferredContent = preferredContentEntry(response.content);
503+ const examples = preferredContent
504+ ? examplesFromMediaType(preferredContent[0], preferredContent[1])
505+ : [];
506+ const headers = headersFromValue(document, response.headers);
507+
508+ return {
509+ statusCode,
510+ ...(asTrimmedString(response.description)
511+ ? { description: asTrimmedString(response.description) }
512+ : {}),
513+ contentTypes,
514+ ...(contentSchemaFromOperationContent(response.content) !== undefined
515+ ? { schema: contentSchemaFromOperationContent(response.content) }
516+ : {}),
517+ ...(examples.length > 0 ? { examples } : {}),
518+ ...(contents.length > 0 ? { contents } : {}),
519+ ...(headers.length > 0 ? { headers } : {}),
520+ };
521+ });
522+
523+ return responses.length > 0 ? responses : undefined;
524+};
525+
526+const documentServersFromDocument = (
527+ document: OpenApiJsonObject,
528+): OpenApiExtractedTool["documentServers"] => {
529+ const servers = serversFromValue(document.servers);
530+ return servers.length > 0 ? servers : undefined;
531+};
532+
533+const operationServersForTool = (
534+ document: OpenApiJsonObject,
535+ tool: OpenApiExtractedTool,
536+): OpenApiExtractedTool["servers"] => {
537+ const operation = operationForTool(document, tool);
538+ if (Object.keys(operation).length === 0) {
539+ return undefined;
540+ }
541+
542+ const operationServers = serversFromValue(operation.servers);
543+ if (operationServers.length > 0) {
544+ return operationServers;
545+ }
546+
547+ const pathItemServers = serversFromValue(pathItemForTool(document, tool).servers);
548+ return pathItemServers.length > 0 ? pathItemServers : undefined;
549+};
550+
551+const securityRequirementFromValue = (
552+ value: unknown,
553+): OpenApiSecurityRequirement | undefined => {
554+ const requirementEntries = asArray(value);
555+ if (requirementEntries.length === 0) {
556+ return {
557+ kind: "none",
558+ };
559+ }
560+
561+ const anyOfItems = requirementEntries.flatMap((entry) => {
562+ const schemes = Object.entries(asObject(entry))
563+ .sort(([left], [right]) => left.localeCompare(right))
564+ .map(([schemeName, rawScopes]) => {
565+ const scopes = asArray(rawScopes)
566+ .flatMap((scope) =>
567+ typeof scope === "string" && scope.trim().length > 0
568+ ? [scope.trim()]
569+ : [],
570+ );
571+
572+ return {
573+ kind: "scheme" as const,
574+ schemeName,
575+ ...(scopes.length > 0 ? { scopes } : {}),
576+ };
577+ });
578+
579+ if (schemes.length === 0) {
580+ return [];
581+ }
582+
583+ return [
584+ schemes.length === 1
585+ ? schemes[0]!
586+ : {
587+ kind: "allOf" as const,
588+ items: schemes,
589+ },
590+ ];
591+ });
592+
593+ if (anyOfItems.length === 0) {
594+ return undefined;
595+ }
596+
597+ return anyOfItems.length === 1
598+ ? anyOfItems[0]
599+ : {
600+ kind: "anyOf",
601+ items: anyOfItems,
602+ };
603+};
604+
605+const authRequirementForTool = (
606+ document: OpenApiJsonObject,
607+ tool: OpenApiExtractedTool,
608+): OpenApiSecurityRequirement | undefined => {
609+ const operation = operationForTool(document, tool);
610+ if (Object.keys(operation).length === 0) {
611+ return undefined;
612+ }
613+
614+ if (Object.prototype.hasOwnProperty.call(operation, "security")) {
615+ return securityRequirementFromValue(operation.security);
616+ }
617+
618+ return securityRequirementFromValue(document.security);
619+};
620+
621+const collectReferencedSecuritySchemeNames = (
622+ authRequirement: OpenApiSecurityRequirement | undefined,
623+ names: Set<string>,
624+): void => {
625+ if (!authRequirement) {
626+ return;
627+ }
628+
629+ switch (authRequirement.kind) {
630+ case "none":
631+ return;
632+ case "scheme":
633+ names.add(authRequirement.schemeName);
634+ return;
635+ case "allOf":
636+ case "anyOf":
637+ for (const item of authRequirement.items) {
638+ collectReferencedSecuritySchemeNames(item, names);
639+ }
640+ }
641+};
642+
643+const oauthFlowRecord = (
644+ value: unknown,
645+): Record<string, {
646+ authorizationUrl?: string;
647+ tokenUrl?: string;
648+ refreshUrl?: string;
649+ scopes?: Record<string, string>;
650+}> | undefined => {
651+ const result = Object.fromEntries(
652+ Object.entries(asObject(value))
653+ .sort(([left], [right]) => left.localeCompare(right))
654+ .map(([flowName, flowValue]) => {
655+ const flowRecord = asObject(flowValue);
656+ const scopes = Object.fromEntries(
657+ Object.entries(asObject(flowRecord.scopes))
658+ .sort(([left], [right]) => left.localeCompare(right))
659+ .map(([scope, description]) => [scope, asTrimmedString(description) ?? ""]),
660+ );
661+
662+ return [flowName, {
663+ ...(asTrimmedString(flowRecord.authorizationUrl)
664+ ? { authorizationUrl: asTrimmedString(flowRecord.authorizationUrl) }
665+ : {}),
666+ ...(asTrimmedString(flowRecord.tokenUrl)
667+ ? { tokenUrl: asTrimmedString(flowRecord.tokenUrl) }
668+ : {}),
669+ ...(asTrimmedString(flowRecord.refreshUrl)
670+ ? { refreshUrl: asTrimmedString(flowRecord.refreshUrl) }
671+ : {}),
672+ ...(Object.keys(scopes).length > 0 ? { scopes } : {}),
673+ }];
674+ }),
675+ );
676+
677+ return Object.keys(result).length > 0 ? result : undefined;
678+};
679+
680+const securitySchemesForTool = (
681+ document: OpenApiJsonObject,
682+ authRequirement: OpenApiSecurityRequirement | undefined,
683+): OpenApiExtractedTool["securitySchemes"] => {
684+ if (!authRequirement || authRequirement.kind === "none") {
685+ return undefined;
686+ }
687+
688+ const schemeNames = new Set<string>();
689+ collectReferencedSecuritySchemeNames(authRequirement, schemeNames);
690+
691+ const securitySchemes = asObject(asObject(document.components).securitySchemes);
692+ const resolved = [...schemeNames]
693+ .sort((left, right) => left.localeCompare(right))
694+ .flatMap((schemeName) => {
695+ const rawScheme = securitySchemes[schemeName];
696+ if (rawScheme === undefined) {
697+ return [];
698+ }
699+
700+ const scheme = asObject(resolveLocalRef(document, rawScheme));
701+ const schemeType = asTrimmedString(scheme.type);
702+ if (!schemeType) {
703+ return [];
704+ }
705+ const normalizedSchemeType: "apiKey" | "http" | "oauth2" | "openIdConnect" =
706+ schemeType === "apiKey"
707+ || schemeType === "http"
708+ || schemeType === "oauth2"
709+ || schemeType === "openIdConnect"
710+ ? schemeType
711+ : "http";
712+
713+ const placementIn = asTrimmedString(scheme.in);
714+ const normalizedPlacementIn: "header" | "query" | "cookie" | undefined =
715+ placementIn === "header" || placementIn === "query" || placementIn === "cookie"
716+ ? placementIn
717+ : undefined;
718+
719+ return [{
720+ schemeName,
721+ schemeType: normalizedSchemeType,
722+ ...(asTrimmedString(scheme.description)
723+ ? { description: asTrimmedString(scheme.description) }
724+ : {}),
725+ ...(normalizedPlacementIn
726+ ? { placementIn: normalizedPlacementIn }
727+ : {}),
728+ ...(asTrimmedString(scheme.name)
729+ ? { placementName: asTrimmedString(scheme.name) }
730+ : {}),
731+ ...(asTrimmedString(scheme.scheme)
732+ ? { scheme: asTrimmedString(scheme.scheme) }
733+ : {}),
734+ ...(asTrimmedString(scheme.bearerFormat)
735+ ? { bearerFormat: asTrimmedString(scheme.bearerFormat) }
736+ : {}),
737+ ...(asTrimmedString(scheme.openIdConnectUrl)
738+ ? { openIdConnectUrl: asTrimmedString(scheme.openIdConnectUrl) }
739+ : {}),
740+ ...(oauthFlowRecord(scheme.flows)
741+ ? { flows: oauthFlowRecord(scheme.flows) }
742+ : {}),
743+ }];
744+ });
745+
746+ return resolved.length > 0 ? resolved : undefined;
747+};
748+
749+const mergeRefHintTable = (
750+ document: OpenApiJsonObject,
751+ manifest: OpenApiToolManifest,
752+ extraRefKeys: ReadonlySet<string>,
753+): OpenApiToolManifest["refHintTable"] => {
754+ const merged: Record<string, string> = {
755+ ...(manifest.refHintTable ?? {}),
756+ };
757+ const queue = [...extraRefKeys].sort((left, right) => left.localeCompare(right));
758+ const seen = new Set<string>(Object.keys(merged));
759+
760+ while (queue.length > 0) {
761+ const refKey = queue.shift()!;
762+ if (seen.has(refKey)) {
763+ continue;
764+ }
765+ seen.add(refKey);
766+
767+ const resolved = resolveLocalRef(document, { $ref: refKey });
768+ merged[refKey] = stableJsonStringify(resolved);
769+
770+ const nestedRefs = new Set<string>();
771+ collectRefKeys(resolved, nestedRefs);
772+ for (const nestedRef of [...nestedRefs].sort((left, right) => left.localeCompare(right))) {
773+ if (!seen.has(nestedRef)) {
774+ queue.push(nestedRef);
775+ }
776+ }
777+ }
778+
779+ return Object.keys(merged).length > 0 ? merged : undefined;
780+};
781+
164782 const enrichManifestFromDocument = (
165783 document: OpenApiJsonObject,
166784 manifest: OpenApiToolManifest,
167−): OpenApiToolManifest => ({
168− ...manifest,
169− tools: manifest.tools.map((tool) => {
785+): OpenApiToolManifest => {
786+ const extraRefKeys = new Set<string>();
787+ const documentServers = documentServersFromDocument(document);
788+ const tools = manifest.tools.map((tool) => {
789+ const parameterRecords = parameterRecordMapForTool(document, tool);
790+ const invocationParameters = tool.invocation.parameters.map((parameter) => {
791+ const record = parameterRecords.get(parameterKey({
792+ location: parameter.location,
793+ name: parameter.name,
794+ }));
795+ const content = contentEntriesFromContent(document, record?.content);
796+
797+ return {
798+ ...parameter,
799+ ...(asTrimmedString(record?.style) ? { style: asTrimmedString(record?.style) } : {}),
800+ ...(typeof record?.explode === "boolean" ? { explode: record.explode } : {}),
801+ ...(typeof record?.allowReserved === "boolean" ? { allowReserved: record.allowReserved } : {}),
802+ ...(content.length > 0 ? { content } : {}),
803+ };
804+ });
805+ const requestBody = requestBodyPayloadForTool(document, tool);
170806 const inputSchema = tool.typing?.inputSchema ?? requestBodySchemaForTool(document, tool);
171807 const outputSchema = tool.typing?.outputSchema ?? responseSchemaForTool(document, tool);
808+ const responses = responseVariantsForTool(document, tool);
809+ const authRequirement = authRequirementForTool(document, tool);
810+ const securitySchemes = securitySchemesForTool(document, authRequirement);
811+ const operationServers = operationServersForTool(document, tool);
812+
813+ for (const parameter of invocationParameters) {
814+ for (const content of parameter.content ?? []) {
815+ collectRefKeys(content.schema, extraRefKeys);
816+ }
817+ }
818+
819+ for (const content of requestBody?.contents ?? []) {
820+ collectRefKeys(content.schema, extraRefKeys);
821+ }
172822
173− if (inputSchema === undefined && outputSchema === undefined) {
174− return tool;
823+ for (const response of responses ?? []) {
824+ collectRefKeys(response.schema, extraRefKeys);
825+ for (const content of response.contents ?? []) {
826+ collectRefKeys(content.schema, extraRefKeys);
827+ }
828+ for (const header of response.headers ?? []) {
829+ collectRefKeys(header.schema, extraRefKeys);
830+ for (const content of header.content ?? []) {
831+ collectRefKeys(content.schema, extraRefKeys);
832+ }
833+ }
175834 }
176835
177836 return {
178837 ...tool,
179− typing: {
180− ...(tool.typing ?? {}),
181− ...(inputSchema !== undefined ? { inputSchema } : {}),
182− ...(outputSchema !== undefined ? { outputSchema } : {}),
838+ invocation: {
839+ ...tool.invocation,
840+ parameters: invocationParameters,
841+ requestBody,
183842 },
843+ ...((inputSchema !== undefined || outputSchema !== undefined)
844+ ? {
845+ typing: {
846+ ...(tool.typing ?? {}),
847+ ...(inputSchema !== undefined ? { inputSchema } : {}),
848+ ...(outputSchema !== undefined ? { outputSchema } : {}),
849+ },
850+ }
851+ : {}),
852+ ...(responses ? { responses } : {}),
853+ ...(authRequirement ? { authRequirement } : {}),
854+ ...(securitySchemes ? { securitySchemes } : {}),
855+ ...(documentServers ? { documentServers } : {}),
856+ ...(operationServers ? { servers: operationServers } : {}),
184857 };
185− }),
186−});
858+ });
859+
860+ return {
861+ ...manifest,
862+ tools,
863+ refHintTable: mergeRefHintTable(document, manifest, extraRefKeys),
864+ };
865+};
187866
188867 export const extractOpenApiManifest = (
189868 sourceName: string,
packages/codemode-openapi/src/openapi-extractor-wasm/openapi_extractor_bg.wasm+0−0

Binary file not shown.

packages/codemode-openapi/src/openapi-http-serialization.ts+515−0
@@ -0,0 +1,515 @@
1+type OpenApiSerializableLocation = "path" | "query" | "header" | "cookie";
2+
3+export type OpenApiSerializableContent = {
4+ mediaType: string;
5+};
6+
7+export type OpenApiSerializableParameter = {
8+ name: string;
9+ location: OpenApiSerializableLocation;
10+ style?: string;
11+ explode?: boolean;
12+ allowReserved?: boolean;
13+ content?: ReadonlyArray<OpenApiSerializableContent>;
14+};
15+
16+export type OpenApiSerializableRequestBody = {
17+ contentTypes?: ReadonlyArray<string>;
18+ contents?: ReadonlyArray<OpenApiSerializableContent>;
19+};
20+
21+export type SerializedOpenApiQueryEntry = {
22+ name: string;
23+ value: string;
24+ allowReserved?: boolean;
25+};
26+
27+export type SerializedOpenApiParameterValue =
28+ | {
29+ kind: "path" | "header";
30+ value: string;
31+ }
32+ | {
33+ kind: "query";
34+ entries: ReadonlyArray<SerializedOpenApiQueryEntry>;
35+ }
36+ | {
37+ kind: "cookie";
38+ pairs: ReadonlyArray<{
39+ name: string;
40+ value: string;
41+ }>;
42+ };
43+
44+const isRecord = (value: unknown): value is Record<string, unknown> =>
45+ value !== null && typeof value === "object" && !Array.isArray(value);
46+
47+const primitiveString = (value: unknown): string => {
48+ if (typeof value === "string") {
49+ return value;
50+ }
51+
52+ if (
53+ typeof value === "number"
54+ || typeof value === "boolean"
55+ || typeof value === "bigint"
56+ ) {
57+ return String(value);
58+ }
59+
60+ if (value === null || value === undefined) {
61+ return "";
62+ }
63+
64+ try {
65+ return JSON.stringify(value);
66+ } catch {
67+ return String(value);
68+ }
69+};
70+
71+const objectEntries = (value: unknown): Array<[string, unknown]> =>
72+ Object.entries(isRecord(value) ? value : {})
73+ .sort(([left], [right]) => left.localeCompare(right));
74+
75+const defaultStyle = (location: OpenApiSerializableLocation): string => {
76+ switch (location) {
77+ case "path":
78+ case "header":
79+ return "simple";
80+ case "cookie":
81+ case "query":
82+ return "form";
83+ }
84+};
85+
86+const defaultExplode = (location: OpenApiSerializableLocation, style: string): boolean =>
87+ location === "query" || location === "cookie"
88+ ? style === "form"
89+ : false;
90+
91+const selectedStyle = (parameter: OpenApiSerializableParameter): string =>
92+ parameter.style ?? defaultStyle(parameter.location);
93+
94+const selectedExplode = (parameter: OpenApiSerializableParameter): boolean =>
95+ parameter.explode ?? defaultExplode(parameter.location, selectedStyle(parameter));
96+
97+const encodePathPart = (value: unknown): string =>
98+ encodeURIComponent(primitiveString(value));
99+
100+const encodeQueryValue = (value: string, allowReserved: boolean): string => {
101+ const encoded = encodeURIComponent(value);
102+ if (!allowReserved) {
103+ return encoded;
104+ }
105+
106+ return encoded
107+ .replace(/%3A/gi, ":")
108+ .replace(/%2F/gi, "/")
109+ .replace(/%3F/gi, "?")
110+ .replace(/%23/gi, "#")
111+ .replace(/%5B/gi, "[")
112+ .replace(/%5D/gi, "]")
113+ .replace(/%40/gi, "@")
114+ .replace(/%21/gi, "!")
115+ .replace(/%24/gi, "$")
116+ .replace(/%26/gi, "&")
117+ .replace(/%27/gi, "'")
118+ .replace(/%28/gi, "(")
119+ .replace(/%29/gi, ")")
120+ .replace(/%2A/gi, "*")
121+ .replace(/%2B/gi, "+")
122+ .replace(/%2C/gi, ",")
123+ .replace(/%3B/gi, ";")
124+ .replace(/%3D/gi, "=");
125+};
126+
127+const preferredContentType = (input: {
128+ contents?: ReadonlyArray<OpenApiSerializableContent>;
129+ contentTypes?: ReadonlyArray<string>;
130+}): string | undefined => {
131+ const candidates = [
132+ ...(input.contents ?? []).map((content) => content.mediaType),
133+ ...(input.contentTypes ?? []),
134+ ];
135+ if (candidates.length === 0) {
136+ return undefined;
137+ }
138+
139+ return candidates.find((mediaType) => mediaType === "application/json")
140+ ?? candidates.find((mediaType) => mediaType.toLowerCase().includes("+json"))
141+ ?? candidates.find((mediaType) => mediaType.toLowerCase().includes("json"))
142+ ?? candidates[0];
143+};
144+
145+const serializeContentValue = (value: unknown, mediaType: string): string => {
146+ const normalized = mediaType.toLowerCase();
147+ if (
148+ normalized === "application/json"
149+ || normalized.includes("+json")
150+ || normalized.includes("json")
151+ ) {
152+ return JSON.stringify(value);
153+ }
154+
155+ if (normalized === "application/x-www-form-urlencoded") {
156+ const params = new URLSearchParams();
157+ for (const [key, entryValue] of objectEntries(value)) {
158+ if (Array.isArray(entryValue)) {
159+ for (const item of entryValue) {
160+ params.append(key, primitiveString(item));
161+ }
162+ continue;
163+ }
164+
165+ params.append(key, primitiveString(entryValue));
166+ }
167+ return params.toString();
168+ }
169+
170+ return primitiveString(value);
171+};
172+
173+const serializeQueryParameter = (
174+ parameter: OpenApiSerializableParameter,
175+ value: unknown,
176+): SerializedOpenApiParameterValue => {
177+ const style = selectedStyle(parameter);
178+ const explode = selectedExplode(parameter);
179+ const allowReserved = parameter.allowReserved === true;
180+ const contentType = preferredContentType({ contents: parameter.content });
181+
182+ if (contentType) {
183+ return {
184+ kind: "query",
185+ entries: [{
186+ name: parameter.name,
187+ value: serializeContentValue(value, contentType),
188+ allowReserved,
189+ }],
190+ };
191+ }
192+
193+ if (Array.isArray(value)) {
194+ if (style === "spaceDelimited") {
195+ return {
196+ kind: "query",
197+ entries: [{
198+ name: parameter.name,
199+ value: value.map((entry) => primitiveString(entry)).join(" "),
200+ allowReserved,
201+ }],
202+ };
203+ }
204+
205+ if (style === "pipeDelimited") {
206+ return {
207+ kind: "query",
208+ entries: [{
209+ name: parameter.name,
210+ value: value.map((entry) => primitiveString(entry)).join("|"),
211+ allowReserved,
212+ }],
213+ };
214+ }
215+
216+ return {
217+ kind: "query",
218+ entries:
219+ explode
220+ ? value.map((entry) => ({
221+ name: parameter.name,
222+ value: primitiveString(entry),
223+ allowReserved,
224+ }))
225+ : [{
226+ name: parameter.name,
227+ value: value.map((entry) => primitiveString(entry)).join(","),
228+ allowReserved,
229+ }],
230+ };
231+ }
232+
233+ if (isRecord(value)) {
234+ const entries = objectEntries(value);
235+
236+ if (style === "deepObject") {
237+ return {
238+ kind: "query",
239+ entries: entries.map(([key, entryValue]) => ({
240+ name: `${parameter.name}[${key}]`,
241+ value: primitiveString(entryValue),
242+ allowReserved,
243+ })),
244+ };
245+ }
246+
247+ return {
248+ kind: "query",
249+ entries:
250+ explode
251+ ? entries.map(([key, entryValue]) => ({
252+ name: key,
253+ value: primitiveString(entryValue),
254+ allowReserved,
255+ }))
256+ : [{
257+ name: parameter.name,
258+ value: entries.flatMap(([key, entryValue]) => [key, primitiveString(entryValue)]).join(","),
259+ allowReserved,
260+ }],
261+ };
262+ }
263+
264+ return {
265+ kind: "query",
266+ entries: [{
267+ name: parameter.name,
268+ value: primitiveString(value),
269+ allowReserved,
270+ }],
271+ };
272+};
273+
274+const serializeHeaderParameter = (
275+ parameter: OpenApiSerializableParameter,
276+ value: unknown,
277+): SerializedOpenApiParameterValue => {
278+ const explode = selectedExplode(parameter);
279+ const contentType = preferredContentType({ contents: parameter.content });
280+ if (contentType) {
281+ return {
282+ kind: "header",
283+ value: serializeContentValue(value, contentType),
284+ };
285+ }
286+
287+ if (Array.isArray(value)) {
288+ return {
289+ kind: "header",
290+ value: value.map((entry) => primitiveString(entry)).join(","),
291+ };
292+ }
293+
294+ if (isRecord(value)) {
295+ const entries = objectEntries(value);
296+ return {
297+ kind: "header",
298+ value:
299+ explode
300+ ? entries.map(([key, entryValue]) => `${key}=${primitiveString(entryValue)}`).join(",")
301+ : entries.flatMap(([key, entryValue]) => [key, primitiveString(entryValue)]).join(","),
302+ };
303+ }
304+
305+ return {
306+ kind: "header",
307+ value: primitiveString(value),
308+ };
309+};
310+
311+const serializeCookieParameter = (
312+ parameter: OpenApiSerializableParameter,
313+ value: unknown,
314+): SerializedOpenApiParameterValue => {
315+ const explode = selectedExplode(parameter);
316+ const contentType = preferredContentType({ contents: parameter.content });
317+ if (contentType) {
318+ return {
319+ kind: "cookie",
320+ pairs: [{
321+ name: parameter.name,
322+ value: serializeContentValue(value, contentType),
323+ }],
324+ };
325+ }
326+
327+ if (Array.isArray(value)) {
328+ return {
329+ kind: "cookie",
330+ pairs:
331+ explode
332+ ? value.map((entry) => ({
333+ name: parameter.name,
334+ value: primitiveString(entry),
335+ }))
336+ : [{
337+ name: parameter.name,
338+ value: value.map((entry) => primitiveString(entry)).join(","),
339+ }],
340+ };
341+ }
342+
343+ if (isRecord(value)) {
344+ const entries = objectEntries(value);
345+ return {
346+ kind: "cookie",
347+ pairs:
348+ explode
349+ ? entries.map(([key, entryValue]) => ({
350+ name: key,
351+ value: primitiveString(entryValue),
352+ }))
353+ : [{
354+ name: parameter.name,
355+ value: entries.flatMap(([key, entryValue]) => [key, primitiveString(entryValue)]).join(","),
356+ }],
357+ };
358+ }
359+
360+ return {
361+ kind: "cookie",
362+ pairs: [{
363+ name: parameter.name,
364+ value: primitiveString(value),
365+ }],
366+ };
367+};
368+
369+const serializePathParameter = (
370+ parameter: OpenApiSerializableParameter,
371+ value: unknown,
372+): SerializedOpenApiParameterValue => {
373+ const style = selectedStyle(parameter);
374+ const explode = selectedExplode(parameter);
375+ const contentType = preferredContentType({ contents: parameter.content });
376+ if (contentType) {
377+ return {
378+ kind: "path",
379+ value: encodePathPart(serializeContentValue(value, contentType)),
380+ };
381+ }
382+
383+ if (Array.isArray(value)) {
384+ if (style === "label") {
385+ return {
386+ kind: "path",
387+ value: `.${value.map((entry) => encodePathPart(entry)).join(explode ? "." : ",")}`,
388+ };
389+ }
390+
391+ if (style === "matrix") {
392+ return {
393+ kind: "path",
394+ value:
395+ explode
396+ ? value.map((entry) => `;${encodeURIComponent(parameter.name)}=${encodePathPart(entry)}`).join("")
397+ : `;${encodeURIComponent(parameter.name)}=${value.map((entry) => encodePathPart(entry)).join(",")}`,
398+ };
399+ }
400+
401+ return {
402+ kind: "path",
403+ value: value.map((entry) => encodePathPart(entry)).join(","),
404+ };
405+ }
406+
407+ if (isRecord(value)) {
408+ const entries = objectEntries(value);
409+
410+ if (style === "label") {
411+ return {
412+ kind: "path",
413+ value:
414+ explode
415+ ? `.${entries.map(([key, entryValue]) => `${encodePathPart(key)}=${encodePathPart(entryValue)}`).join(".")}`
416+ : `.${entries.flatMap(([key, entryValue]) => [encodePathPart(key), encodePathPart(entryValue)]).join(",")}`,
417+ };
418+ }
419+
420+ if (style === "matrix") {
421+ return {
422+ kind: "path",
423+ value:
424+ explode
425+ ? entries.map(([key, entryValue]) => `;${encodeURIComponent(key)}=${encodePathPart(entryValue)}`).join("")
426+ : `;${encodeURIComponent(parameter.name)}=${entries.flatMap(([key, entryValue]) => [
427+ encodePathPart(key),
428+ encodePathPart(entryValue),
429+ ]).join(",")}`,
430+ };
431+ }
432+
433+ return {
434+ kind: "path",
435+ value:
436+ explode
437+ ? entries.map(([key, entryValue]) => `${encodePathPart(key)}=${encodePathPart(entryValue)}`).join(",")
438+ : entries.flatMap(([key, entryValue]) => [encodePathPart(key), encodePathPart(entryValue)]).join(","),
439+ };
440+ }
441+
442+ if (style === "label") {
443+ return {
444+ kind: "path",
445+ value: `.${encodePathPart(value)}`,
446+ };
447+ }
448+
449+ if (style === "matrix") {
450+ return {
451+ kind: "path",
452+ value: `;${encodeURIComponent(parameter.name)}=${encodePathPart(value)}`,
453+ };
454+ }
455+
456+ return {
457+ kind: "path",
458+ value: encodePathPart(value),
459+ };
460+};
461+
462+export const serializeOpenApiParameterValue = (
463+ parameter: OpenApiSerializableParameter,
464+ value: unknown,
465+): SerializedOpenApiParameterValue => {
466+ switch (parameter.location) {
467+ case "path":
468+ return serializePathParameter(parameter, value);
469+ case "query":
470+ return serializeQueryParameter(parameter, value);
471+ case "header":
472+ return serializeHeaderParameter(parameter, value);
473+ case "cookie":
474+ return serializeCookieParameter(parameter, value);
475+ }
476+};
477+
478+export const withSerializedQueryEntries = (
479+ url: URL,
480+ entries: ReadonlyArray<SerializedOpenApiQueryEntry>,
481+): string => {
482+ if (entries.length === 0) {
483+ return url.toString();
484+ }
485+
486+ const suffix = entries
487+ .map((entry) =>
488+ `${encodeURIComponent(entry.name)}=${encodeQueryValue(entry.value, entry.allowReserved === true)}`
489+ )
490+ .join("&");
491+
492+ const urlText = url.toString();
493+ const [withoutHash, hashFragment] = urlText.split("#", 2);
494+ const separator =
495+ withoutHash.includes("?")
496+ ? (withoutHash.endsWith("?") || withoutHash.endsWith("&") ? "" : "&")
497+ : "?";
498+
499+ return `${withoutHash}${separator}${suffix}${hashFragment ? `#${hashFragment}` : ""}`;
500+};
501+
502+export const serializeOpenApiRequestBody = (input: {
503+ requestBody: OpenApiSerializableRequestBody;
504+ body: unknown;
505+}): {
506+ contentType: string;
507+ bodyText: string;
508+} => {
509+ const contentType = preferredContentType(input.requestBody) ?? "application/json";
510+
511+ return {
512+ contentType,
513+ bodyText: serializeContentValue(input.body, contentType),
514+ };
515+};
packages/codemode-openapi/src/openapi-tool-presentation.test.ts+244−0
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs"; · 2 unmodified lines
33 import { describe, expect, it } from "@effect/vitest";
44 import * as Effect from "effect/Effect";
55
6+import type { OpenApiToolDefinition } from "./openapi-definitions";
67 import { compileOpenApiToolDefinitions } from "./openapi-definitions";
78 import { extractOpenApiManifest } from "./openapi-extraction";
89 import { buildOpenApiToolPresentation } from "./openapi-tool-presentation";
@@ -14,6 +15,188 @@ const readFixture = (name: string): string => · 5 unmodified lines
1415 );
1516
1617 describe("buildOpenApiToolPresentation", () => {
18+ it("adds parameter descriptions to flattened call schemas", () => {
19+ const definition: OpenApiToolDefinition = {
20+ toolId: "accessGroups.deleteProject",
21+ rawToolId: "deleteAccessGroupProject",
22+ name: "Delete an access group project",
23+ description: "Allows deletion of an access group project",
24+ group: "accessGroups",
25+ leaf: "deleteProject",
26+ tags: [],
27+ versionSegment: "v1",
28+ method: "delete",
29+ path: "/v1/access-groups/{accessGroupIdOrName}/projects/{projectId}",
30+ invocation: {
31+ method: "delete",
32+ pathTemplate: "/v1/access-groups/{accessGroupIdOrName}/projects/{projectId}",
33+ parameters: [
34+ {
35+ name: "accessGroupIdOrName",
36+ location: "path",
37+ required: true,
38+ },
39+ {
40+ name: "projectId",
41+ location: "path",
42+ required: true,
43+ },
44+ {
45+ name: "teamId",
46+ location: "query",
47+ required: false,
48+ },
49+ {
50+ name: "slug",
51+ location: "query",
52+ required: false,
53+ },
54+ ],
55+ requestBody: null,
56+ },
57+ operationHash: "deadbeefcafebabe",
58+ typing: {
59+ inputSchema: {
60+ type: "object",
61+ properties: {
62+ accessGroupIdOrName: { type: "string" },
63+ projectId: { type: "string" },
64+ teamId: { type: "string" },
65+ slug: { type: "string" },
66+ },
67+ required: ["accessGroupIdOrName", "projectId"],
68+ additionalProperties: false,
69+ },
70+ },
71+ documentation: {
72+ summary: "Delete an access group project",
73+ parameters: [
74+ {
75+ name: "accessGroupIdOrName",
76+ location: "path",
77+ required: true,
78+ description: "The access group identifier or slug.",
79+ },
80+ {
81+ name: "projectId",
82+ location: "path",
83+ required: true,
84+ description: "The project identifier.",
85+ },
86+ {
87+ name: "teamId",
88+ location: "query",
89+ required: false,
90+ description: "The Team identifier to perform the request on behalf of.",
91+ },
92+ {
93+ name: "slug",
94+ location: "query",
95+ required: false,
96+ description: "The Team slug to perform the request on behalf of.",
97+ },
98+ ],
99+ },
100+ };
101+
102+ const presentation = buildOpenApiToolPresentation({ definition });
103+ const schema = presentation.inputSchema as {
104+ properties?: Record<string, { description?: string }>;
105+ };
106+
107+ expect(schema.properties?.accessGroupIdOrName?.description).toBe(
108+ "The access group identifier or slug.",
109+ );
110+ expect(schema.properties?.projectId?.description).toBe(
111+ "The project identifier.",
112+ );
113+ expect(schema.properties?.teamId?.description).toBe(
114+ "The Team identifier to perform the request on behalf of.",
115+ );
116+ expect(schema.properties?.slug?.description).toBe(
117+ "The Team slug to perform the request on behalf of.",
118+ );
119+ });
120+
121+ it("uses the request body property schema instead of nesting the full input schema", () => {
122+ const definition: OpenApiToolDefinition = {
123+ toolId: "projects.update",
124+ rawToolId: "updateProject",
125+ name: "Update project",
126+ description: "Updates a project.",
127+ group: "projects",
128+ leaf: "update",
129+ tags: [],
130+ versionSegment: "v1",
131+ method: "patch",
132+ path: "/v1/projects/{projectId}",
133+ invocation: {
134+ method: "patch",
135+ pathTemplate: "/v1/projects/{projectId}",
136+ parameters: [
137+ {
138+ name: "projectId",
139+ location: "path",
140+ required: true,
141+ },
142+ ],
143+ requestBody: {
144+ required: true,
145+ contentTypes: ["application/json"],
146+ },
147+ },
148+ operationHash: "feedfacecafebeef",
149+ typing: {
150+ inputSchema: {
151+ type: "object",
152+ properties: {
153+ projectId: {
154+ type: "string",
155+ },
156+ body: {
157+ type: "object",
158+ properties: {
159+ name: {
160+ type: "string",
161+ },
162+ },
163+ required: ["name"],
164+ additionalProperties: false,
165+ },
166+ },
167+ required: ["projectId", "body"],
168+ additionalProperties: false,
169+ },
170+ },
171+ documentation: {
172+ parameters: [
173+ {
174+ name: "projectId",
175+ location: "path",
176+ required: true,
177+ description: "The project identifier.",
178+ },
179+ ],
180+ requestBody: {
181+ description: "Project update payload.",
182+ },
183+ },
184+ };
185+
186+ const presentation = buildOpenApiToolPresentation({ definition });
187+ const schema = presentation.inputSchema as {
188+ properties?: Record<string, unknown>;
189+ };
190+ const body = schema.properties?.body as {
191+ description?: string;
192+ properties?: Record<string, unknown>;
193+ };
194+
195+ expect(body.description).toBe("Project update payload.");
196+ expect(body.properties?.name).toEqual({ type: "string" });
197+ expect(body.properties?.body).toBeUndefined();
198+ });
199+
17200 it.effect(
18201 "resolves request and response schemas from ref hints for the real Neon OpenAPI spec",
19202 () =>
@@ -104,4 +287,65 @@ describe("buildOpenApiToolPresentation", () => { · 84 unmodified lines
104287 }),
105288 120_000,
106289 );
290+
291+ it.effect(
292+ "extracts parameter schemas and descriptions for parameter-only Vercel operations",
293+ () =>
294+ Effect.gen(function* () {
295+ const specText = readFixture("vercel-openapi.json");
296+ const manifest = yield* extractOpenApiManifest("vercel", specText);
297+ const definition = compileOpenApiToolDefinitions(manifest).find(
298+ (candidate) => candidate.toolId === "accessGroups.listAccessGroupProjects",
299+ );
300+
301+ expect(definition).toBeDefined();
302+
303+ const presentation = buildOpenApiToolPresentation({
304+ definition: definition!,
305+ refHintTable: manifest.refHintTable,
306+ });
307+
308+ expect(definition?.documentation?.parameters).toEqual(
309+ expect.arrayContaining([
310+ expect.objectContaining({
311+ name: "idOrName",
312+ description: "The ID or name of the Access Group.",
313+ }),
314+ expect.objectContaining({
315+ name: "limit",
316+ description: "Limit how many access group projects should be returned.",
317+ }),
318+ ]),
319+ );
320+ expect(presentation.inputSchema).toMatchObject({
321+ type: "object",
322+ properties: {
323+ idOrName: {
324+ type: "string",
325+ description: "The ID or name of the Access Group.",
326+ },
327+ limit: {
328+ type: "integer",
329+ description: "Limit how many access group projects should be returned.",
330+ minimum: 1,
331+ maximum: 100,
332+ },
333+ next: {
334+ type: "string",
335+ description: "Continuation cursor to retrieve the next page of results.",
336+ },
337+ teamId: {
338+ type: "string",
339+ description: "The Team identifier to perform the request on behalf of.",
340+ },
341+ slug: {
342+ type: "string",
343+ description: "The Team slug to perform the request on behalf of.",
344+ },
345+ },
346+ required: ["idOrName"],
347+ });
348+ }),
349+ 120_000,
350+ );
107351 });
packages/codemode-openapi/src/openapi-tool-presentation.ts+128−18
@@ -3,8 +3,10 @@ import { typeSignatureFromSchema } from "@executor/codemode-core"; · 2 unmodified lines
33
44 import type {
55 OpenApiExample,
6+ OpenApiMediaContent,
67 OpenApiInvocationPayload,
78 OpenApiRefHintTable,
9+ OpenApiResponseVariant,
810 OpenApiToolDocumentation,
911 OpenApiToolProviderData,
1012 } from "./openapi-types";
@@ -51,8 +53,74 @@ const firstExample = ( · 40 unmodified lines
5153 examples: ReadonlyArray<OpenApiExample> | undefined,
5254 ): OpenApiExample | undefined => examples?.[0];
5355
56+const schemaProperty = (
57+ schema: unknown,
58+ propertyName: string,
59+): unknown | undefined => {
60+ const record = asRecord(schema);
61+ const properties = asRecord(record.properties);
62+ return properties[propertyName];
63+};
64+
65+const groupedSchemaForParameter = (
66+ schema: unknown,
67+ location: OpenApiInvocationPayload["parameters"][number]["location"],
68+ name: string,
69+): unknown | undefined => {
70+ const direct = schemaProperty(schema, name);
71+ if (direct !== undefined) {
72+ return direct;
73+ }
74+
75+ const groupKey =
76+ location === "header"
77+ ? "headers"
78+ : location === "cookie"
79+ ? "cookies"
80+ : location;
81+ const groupSchema = schemaProperty(schema, groupKey);
82+ return groupSchema === undefined ? undefined : schemaProperty(groupSchema, name);
83+};
84+
85+const preferredContentSchema = (
86+ contents: ReadonlyArray<OpenApiMediaContent> | undefined,
87+): unknown | undefined => {
88+ if (!contents || contents.length === 0) {
89+ return undefined;
90+ }
91+
92+ const preferred = [...contents].sort((left, right) => left.mediaType.localeCompare(right.mediaType))
93+ .find((content) => content.mediaType === "application/json")
94+ ?? [...contents].find((content) => content.mediaType.toLowerCase().includes("+json"))
95+ ?? [...contents].find((content) => content.mediaType.toLowerCase().includes("json"))
96+ ?? contents[0];
97+
98+ return preferred?.schema;
99+};
100+
101+const withDescription = (
102+ schema: unknown,
103+ description: string | undefined,
104+): unknown => {
105+ if (!description) {
106+ return schema;
107+ }
108+
109+ const record = asRecord(schema);
110+ if (Object.keys(record).length === 0) {
111+ return schema;
112+ }
113+
114+ return {
115+ ...record,
116+ description,
117+ };
118+};
119+
54120 const callInputSchemaFromInvocation = (input: {
55121 invocation: OpenApiInvocationPayload;
122+ documentation?: OpenApiToolDocumentation;
123+ parameterSourceSchema?: unknown;
56124 requestBodySchema?: unknown;
57125 }): Record<string, unknown> | undefined => {
58126 const invocation = input.invocation;
@@ -60,18 +128,33 @@ const callInputSchemaFromInvocation = (input: { · 1 unmodified line
60128 const required: string[] = [];
61129
62130 for (const parameter of invocation.parameters) {
63− properties[parameter.name] = {
131+ const matchingDocs = input.documentation?.parameters.find((candidate) =>
132+ candidate.name === parameter.name && candidate.location === parameter.location
133+ );
134+ const parameterSchema = groupedSchemaForParameter(
135+ input.parameterSourceSchema,
136+ parameter.location,
137+ parameter.name,
138+ ) ?? preferredContentSchema(parameter.content) ?? {
64139 type: "string",
65140 };
141+
142+ properties[parameter.name] = withDescription(
143+ parameterSchema,
144+ matchingDocs?.description,
145+ );
66146 if (parameter.required) {
67147 required.push(parameter.name);
68148 }
69149 }
70150
71151 if (invocation.requestBody) {
72− properties.body = input.requestBodySchema ?? {
73− type: "object",
74− };
152+ properties.body = withDescription(
153+ input.requestBodySchema ?? preferredContentSchema(invocation.requestBody.contents) ?? {
154+ type: "object",
155+ },
156+ input.documentation?.requestBody?.description,
157+ );
75158 if (invocation.requestBody.required) {
76159 required.push("body");
77160 }
@@ -130,23 +213,22 @@ const resolvePresentationSchemas = (input: { · 52 unmodified lines
130213 input.refHintTable,
131214 );
132215 const requestBodySchema =
133− resolvedTyping.inputSchema
216+ schemaProperty(resolvedTyping.inputSchema, "body")
217+ ?? schemaProperty(resolvedTyping.inputSchema, "input")
134218 ?? (resolvedRequestBodySchema !== undefined && resolvedRequestBodySchema !== null
135219 ? resolvedRequestBodySchema
136220 : undefined);
137221
138− const inputSchema =
139− input.definition.invocation.requestBody !== null
140− ? callInputSchemaFromInvocation({
141− invocation: input.definition.invocation,
142− ...(requestBodySchema !== undefined
143− ? { requestBodySchema }
144− : {}),
145− })
146− : resolvedTyping.inputSchema
147− ?? callInputSchemaFromInvocation({
148− invocation: input.definition.invocation,
149− });
222+ const inputSchema = callInputSchemaFromInvocation({
223+ invocation: input.definition.invocation,
224+ documentation: input.definition.documentation,
225+ parameterSourceSchema: resolvedTyping.inputSchema,
226+ ...(requestBodySchema !== undefined
227+ ? { requestBodySchema }
228+ : {}),
229+ })
230+ ?? resolvedTyping.inputSchema
231+ ?? undefined;
150232 const outputSchema =
151233 resolvedTyping.outputSchema
152234 ?? resolveSchemaWithRefHints(
@@ -160,6 +242,27 @@ const resolvePresentationSchemas = (input: { · 7 unmodified lines
160242 };
161243 };
162244
245+const resolveResponseVariantsWithRefHints = (input: {
246+ variants: ReadonlyArray<OpenApiResponseVariant> | undefined;
247+ refHintTable?: Readonly<OpenApiRefHintTable>;
248+}): ReadonlyArray<OpenApiResponseVariant> | undefined => {
249+ if (!input.variants || input.variants.length === 0) {
250+ return undefined;
251+ }
252+
253+ return input.variants.map((variant) => {
254+ const resolvedSchema = resolveSchemaWithRefHints(
255+ variant.schema,
256+ input.refHintTable,
257+ );
258+
259+ return {
260+ ...variant,
261+ ...(resolvedSchema !== undefined ? { schema: resolvedSchema } : {}),
262+ };
263+ });
264+};
265+
163266 const buildExampleInput = (
164267 documentation: OpenApiToolDocumentation | undefined,
165268 ): Record<string, unknown> | undefined => {
@@ -208,6 +311,10 @@ export const buildOpenApiToolPresentation = (input: { · 42 unmodified lines
208311 refHintTable?: Readonly<OpenApiRefHintTable>;
209312 }): OpenApiToolPresentation => {
210313 const { inputSchema, outputSchema } = resolvePresentationSchemas(input);
314+ const responses = resolveResponseVariantsWithRefHints({
315+ variants: input.definition.responses,
316+ refHintTable: input.refHintTable,
317+ });
211318 const exampleInput = buildExampleInput(input.definition.documentation);
212319 const exampleOutput = buildExampleOutput(input.definition.documentation);
213320
@@ -218,6 +325,9 @@ export const buildOpenApiToolPresentation = (input: { · 4 unmodified lines
218325 ...(outputSchema !== undefined ? { outputSchema } : {}),
219326 ...(exampleInput !== undefined ? { exampleInput } : {}),
220327 ...(exampleOutput !== undefined ? { exampleOutput } : {}),
221− providerData: openApiProviderDataFromDefinition(input.definition),
328+ providerData: {
329+ ...openApiProviderDataFromDefinition(input.definition),
330+ ...(responses ? { responses } : {}),
331+ } satisfies OpenApiToolProviderData,
222332 };
223333 };
packages/codemode-openapi/src/openapi-tools.test.ts+532−0
@@ -296,6 +296,315 @@ describe("openapi-tools", () => { · 295 unmodified lines
296296 }),
297297 );
298298
299+ it.effect("enriches extracted manifests with OpenAPI auth and response variants", () =>
300+ Effect.gen(function* () {
301+ const manifest = yield* extractOpenApiManifest("secure-demo", {
302+ openapi: "3.1.0",
303+ info: {
304+ title: "Secure Demo",
305+ version: "1.0.0",
306+ },
307+ security: [{
308+ bearerToken: [],
309+ }],
310+ paths: {
311+ "/projects/{id}": {
312+ get: {
313+ operationId: "projects.getProject",
314+ security: [],
315+ parameters: [{
316+ name: "id",
317+ in: "path",
318+ required: true,
319+ schema: {
320+ type: "string",
321+ },
322+ }],
323+ responses: {
324+ "200": {
325+ description: "Project",
326+ content: {
327+ "application/json": {
328+ schema: {
329+ $ref: "#/$defs/Project",
330+ },
331+ },
332+ },
333+ },
334+ "404": {
335+ description: "Missing",
336+ content: {
337+ "application/json": {
338+ schema: {
339+ $ref: "#/$defs/Missing",
340+ },
341+ },
342+ },
343+ },
344+ default: {
345+ description: "Unexpected",
346+ content: {
347+ "application/json": {
348+ schema: {
349+ type: "object",
350+ properties: {
351+ error: {
352+ type: "string",
353+ },
354+ },
355+ },
356+ },
357+ },
358+ },
359+ },
360+ },
361+ },
362+ "/teams": {
363+ get: {
364+ operationId: "teams.listTeams",
365+ security: [
366+ { bearerToken: [] },
367+ { apiKeyHeader: [] },
368+ ],
369+ responses: {
370+ "200": {
371+ description: "Teams",
372+ content: {
373+ "application/json": {
374+ schema: {
375+ type: "object",
376+ properties: {
377+ teams: {
378+ type: "array",
379+ items: {
380+ type: "string",
381+ },
382+ },
383+ },
384+ },
385+ },
386+ },
387+ },
388+ },
389+ },
390+ },
391+ },
392+ components: {
393+ securitySchemes: {
394+ bearerToken: {
395+ type: "http",
396+ scheme: "bearer",
397+ bearerFormat: "JWT",
398+ description: "Bearer access token.",
399+ },
400+ apiKeyHeader: {
401+ type: "apiKey",
402+ in: "header",
403+ name: "x-api-key",
404+ },
405+ },
406+ },
407+ $defs: {
408+ Project: {
409+ type: "object",
410+ properties: {
411+ id: {
412+ type: "string",
413+ },
414+ },
415+ required: ["id"],
416+ },
417+ Missing: {
418+ type: "object",
419+ properties: {
420+ code: {
421+ type: "string",
422+ },
423+ },
424+ required: ["code"],
425+ },
426+ },
427+ });
428+
429+ const projectTool = manifest.tools.find((tool) => tool.toolId === "projects.getProject");
430+ const teamsTool = manifest.tools.find((tool) => tool.toolId === "teams.listTeams");
431+
432+ expect(projectTool?.authRequirement).toEqual({
433+ kind: "none",
434+ });
435+ expect(projectTool?.responses?.map((response) => response.statusCode)).toEqual([
436+ "200",
437+ "default",
438+ "404",
439+ ]);
440+ expect(projectTool?.responses?.find((response) => response.statusCode === "404")?.schema).toMatchObject({
441+ $ref: "#/$defs/Missing",
442+ });
443+ expect(teamsTool?.authRequirement).toMatchObject({
444+ kind: "anyOf",
445+ });
446+ expect(teamsTool?.securitySchemes).toMatchObject([
447+ {
448+ schemeName: "apiKeyHeader",
449+ schemeType: "apiKey",
450+ placementIn: "header",
451+ placementName: "x-api-key",
452+ },
453+ {
454+ schemeName: "bearerToken",
455+ schemeType: "http",
456+ scheme: "bearer",
457+ bearerFormat: "JWT",
458+ },
459+ ]);
460+ expect(Object.keys(manifest.refHintTable ?? {})).toEqual(
461+ expect.arrayContaining([
462+ "#/$defs/Project",
463+ "#/$defs/Missing",
464+ ]),
465+ );
466+ }),
467+ );
468+
469+ it.effect("preserves OpenAPI servers, parameter serialization, and response headers", () =>
470+ Effect.gen(function* () {
471+ const manifest = yield* extractOpenApiManifest("serialized-demo", {
472+ openapi: "3.1.0",
473+ info: {
474+ title: "Serialized Demo",
475+ version: "1.0.0",
476+ },
477+ servers: [{
478+ url: "https://api.example.test/{version}",
479+ variables: {
480+ version: {
481+ default: "v1",
482+ },
483+ },
484+ }],
485+ paths: {
486+ "/items/{itemId}": {
487+ servers: [{
488+ url: "https://regional.example.test/base",
489+ }],
490+ get: {
491+ operationId: "items.getItem",
492+ parameters: [
493+ {
494+ name: "itemId",
495+ in: "path",
496+ required: true,
497+ style: "label",
498+ explode: true,
499+ schema: {
500+ type: "array",
501+ items: {
502+ type: "string",
503+ },
504+ },
505+ },
506+ {
507+ name: "filter",
508+ in: "query",
509+ style: "deepObject",
510+ explode: true,
511+ schema: {
512+ type: "object",
513+ additionalProperties: {
514+ type: "string",
515+ },
516+ },
517+ },
518+ {
519+ name: "search",
520+ in: "query",
521+ allowReserved: true,
522+ schema: {
523+ type: "string",
524+ },
525+ },
526+ ],
527+ responses: {
528+ "200": {
529+ description: "Item",
530+ headers: {
531+ "x-next-cursor": {
532+ description: "Continuation cursor.",
533+ schema: {
534+ type: "string",
535+ },
536+ },
537+ },
538+ content: {
539+ "application/json": {
540+ schema: {
541+ type: "object",
542+ properties: {
543+ id: {
544+ type: "string",
545+ },
546+ },
547+ required: ["id"],
548+ },
549+ },
550+ "text/plain": {
551+ schema: {
552+ type: "string",
553+ },
554+ },
555+ },
556+ },
557+ },
558+ },
559+ },
560+ },
561+ });
562+
563+ const tool = manifest.tools.find((candidate) => candidate.toolId === "items.getItem");
564+
565+ expect(tool?.documentServers).toMatchObject([
566+ {
567+ url: "https://api.example.test/{version}",
568+ variables: {
569+ version: "v1",
570+ },
571+ },
572+ ]);
573+ expect(tool?.servers).toMatchObject([
574+ {
575+ url: "https://regional.example.test/base",
576+ },
577+ ]);
578+ expect(tool?.invocation.parameters).toMatchObject([
579+ {
580+ name: "itemId",
581+ style: "label",
582+ explode: true,
583+ },
584+ {
585+ name: "filter",
586+ style: "deepObject",
587+ explode: true,
588+ },
589+ {
590+ name: "search",
591+ allowReserved: true,
592+ },
593+ ]);
594+ expect(tool?.responses?.[0]).toMatchObject({
595+ statusCode: "200",
596+ contentTypes: ["application/json", "text/plain"],
597+ headers: [
598+ {
599+ name: "x-next-cursor",
600+ description: "Continuation cursor.",
601+ },
602+ ],
603+ });
604+ expect(tool?.responses?.[0]?.contents).toHaveLength(2);
605+ }),
606+ );
607+
299608 it.scoped("accepts OpenApi.fromApi generated specs", () =>
300609 Effect.gen(function* () {
301610 const server = yield* makeTestServer;
@@ -396,6 +705,229 @@ describe("openapi-tools", () => { · 94 unmodified lines
396705 }),
397706 );
398707
708+ it.scoped("serializes OpenAPI styles, allowReserved, and form bodies when invoking tools", () =>
709+ Effect.gen(function* () {
710+ const requests: TestServer["requests"] = [];
711+ const server = yield* Effect.acquireRelease(
712+ Effect.promise<TestServer>(
713+ () =>
714+ new Promise<TestServer>((resolve, reject) => {
715+ const httpServer = createServer((request, response) => {
716+ const url = new URL(
717+ request.url ?? "/",
718+ `http://${request.headers.host ?? "127.0.0.1"}`,
719+ );
720+ let body = "";
721+
722+ request.setEncoding("utf8");
723+ request.on("data", (chunk) => {
724+ body += chunk;
725+ });
726+ request.on("end", () => {
727+ requests.push({
728+ method: request.method ?? "GET",
729+ path: url.pathname,
730+ query: url.search,
731+ body,
732+ headers: requestHeadersFromNode(request),
733+ });
734+ response.statusCode = 200;
735+ response.setHeader("content-type", "application/json");
736+ response.end(JSON.stringify({ ok: true }));
737+ });
738+ });
739+
740+ httpServer.once("error", reject);
741+ httpServer.listen(0, "127.0.0.1", () => {
742+ const address = httpServer.address();
743+ if (!address || typeof address === "string") {
744+ reject(new Error("failed to resolve serialization test server address"));
745+ return;
746+ }
747+
748+ resolve({
749+ baseUrl: `http://127.0.0.1:${address.port}`,
750+ requests,
751+ close: async () => {
752+ await new Promise<void>((closeResolve, closeReject) => {
753+ httpServer.close((error: Error | undefined) => {
754+ if (error) {
755+ closeReject(error);
756+ return;
757+ }
758+ closeResolve();
759+ });
760+ });
761+ },
762+ });
763+ });
764+ }),
765+ ),
766+ (resource) =>
767+ Effect.tryPromise({
768+ try: () => resource.close(),
769+ catch: (error: unknown) =>
770+ error instanceof Error ? error : new Error(String(error)),
771+ }).pipe(Effect.orDie),
772+ );
773+
774+ const extracted = yield* createOpenApiToolsFromSpec({
775+ sourceName: "serialized-demo",
776+ openApiSpec: {
777+ openapi: "3.1.0",
778+ info: {
779+ title: "Serialized Demo",
780+ version: "1.0.0",
781+ },
782+ paths: {
783+ "/items/{itemId}": {
784+ get: {
785+ operationId: "items.getItem",
786+ parameters: [
787+ {
788+ name: "itemId",
789+ in: "path",
790+ required: true,
791+ style: "label",
792+ explode: true,
793+ schema: {
794+ type: "array",
795+ items: {
796+ type: "string",
797+ },
798+ },
799+ },
800+ {
801+ name: "filter",
802+ in: "query",
803+ style: "deepObject",
804+ explode: true,
805+ schema: {
806+ type: "object",
807+ additionalProperties: {
808+ type: "string",
809+ },
810+ },
811+ },
812+ {
813+ name: "search",
814+ in: "query",
815+ allowReserved: true,
816+ schema: {
817+ type: "string",
818+ },
819+ },
820+ {
821+ name: "X-Trace",
822+ in: "header",
823+ style: "simple",
824+ explode: false,
825+ schema: {
826+ type: "array",
827+ items: {
828+ type: "string",
829+ },
830+ },
831+ },
832+ ],
833+ responses: {
834+ "200": {
835+ description: "ok",
836+ content: {
837+ "application/json": {
838+ schema: {
839+ type: "object",
840+ properties: {
841+ ok: {
842+ type: "boolean",
843+ },
844+ },
845+ },
846+ },
847+ },
848+ },
849+ },
850+ },
851+ },
852+ "/forms": {
853+ post: {
854+ operationId: "forms.submit",
855+ requestBody: {
856+ required: true,
857+ content: {
858+ "application/x-www-form-urlencoded": {
859+ schema: {
860+ type: "object",
861+ properties: {
862+ title: {
863+ type: "string",
864+ },
865+ state: {
866+ type: "string",
867+ },
868+ },
869+ required: ["title"],
870+ },
871+ },
872+ },
873+ },
874+ responses: {
875+ "200": {
876+ description: "ok",
877+ content: {
878+ "application/json": {
879+ schema: {
880+ type: "object",
881+ properties: {
882+ ok: {
883+ type: "boolean",
884+ },
885+ },
886+ },
887+ },
888+ },
889+ },
890+ },
891+ },
892+ },
893+ },
894+ },
895+ baseUrl: server.baseUrl,
896+ namespace: "source.serialized",
897+ });
898+
899+ const getItem = resolveToolExecutor(extracted.tools, "source.serialized.items.getItem");
900+ const submitForm = resolveToolExecutor(extracted.tools, "source.serialized.forms.submit");
901+
902+ yield* Effect.promise(() =>
903+ getItem({
904+ itemId: ["alpha", "beta"],
905+ filter: {
906+ status: "open",
907+ },
908+ search: "refs/heads/main?draft=true",
909+ "X-Trace": ["a", "b"],
910+ }),
911+ );
912+ yield* Effect.promise(() =>
913+ submitForm({
914+ body: {
915+ title: "Bug report",
916+ state: "open",
917+ },
918+ }),
919+ );
920+
921+ expect(requests[0]?.path).toBe("/items/.alpha.beta");
922+ expect(requests[0]?.query).toContain("filter%5Bstatus%5D=open");
923+ expect(requests[0]?.query).toContain("search=refs/heads/main?draft=true");
924+ expect(requests[0]?.headers["x-trace"]).toBe("a,b");
925+ expect(requests[1]?.headers["content-type"]).toContain("application/x-www-form-urlencoded");
926+ expect(requests[1]?.body).toContain("title=Bug+report");
927+ expect(requests[1]?.body).toContain("state=open");
928+ }),
929+ );
930+
399931 it.effect("throws on non-2xx OpenAPI responses", () =>
400932 Effect.gen(function* () {
401933 const manifest = yield* extractOpenApiManifest("generated", generatedOpenApiSpec);
packages/codemode-openapi/src/openapi-tools.ts+32−36
@@ -22,6 +22,12 @@ import { · 21 unmodified lines
2222 compileOpenApiToolDefinitions,
2323 type OpenApiToolDefinition,
2424 } from "./openapi-definitions";
25+import {
26+ serializeOpenApiParameterValue,
27+ serializeOpenApiRequestBody,
28+ withSerializedQueryEntries,
29+ type SerializedOpenApiQueryEntry,
30+} from "./openapi-http-serialization";
2531 import { buildOpenApiToolPresentation } from "./openapi-tool-presentation";
2632 import { resolveSchemaWithRefHints } from "./openapi-schema-refs";
2733 import {
@@ -67,22 +73,6 @@ const parameterContainerKeys: Record< · 39 unmodified lines
6773 cookie: ["cookies", "cookie"],
6874 };
6975
70−const argsValueToString = (value: unknown): string => {
71− if (typeof value === "string") {
72− return value;
73− }
74−
75− if (
76− typeof value === "number"
77− || typeof value === "boolean"
78− || typeof value === "bigint"
79− ) {
80− return String(value);
81− }
82−
83− return String(value);
84−};
85−
8676 const readParameterValue = (
8777 args: OpenApiToolArgs,
8878 parameter: OpenApiToolParameter,
@@ -136,9 +126,12 @@ const replacePathTemplate = ( · 47 unmodified lines
136126 continue;
137127 }
138128
129+ const serialized = serializeOpenApiParameterValue(parameter, parameterValue);
139130 resolvedPath = resolvedPath.replaceAll(
140131 `{${parameter.name}}`,
141− encodeURIComponent(String(parameterValue)),
132+ serialized.kind === "path"
133+ ? serialized.value
134+ : encodeURIComponent(String(parameterValue)),
142135 );
143136 }
144137
@@ -324,7 +317,7 @@ const buildFetchRequest = (input: { · 179 unmodified lines
324317 defaultHeaders: Readonly<Record<string, string>>;
325318 credentialPlacements: HttpRequestPlacements;
326319 }): {
327− url: URL;
320+ url: string;
328321 method: string;
329322 headers: Record<string, string>;
330323 body?: string;
@@ -340,6 +333,7 @@ const buildFetchRequest = (input: { · 9 unmodified lines
340333 ...input.defaultHeaders,
341334 };
342335 const cookieParts: Array<string> = [];
336+ const queryEntries: SerializedOpenApiQueryEntry[] = [];
343337
344338 for (const parameter of input.payload.parameters) {
345339 if (parameter.location === "path") {
@@ -358,14 +352,18 @@ const buildFetchRequest = (input: { · 12 unmodified lines
358352 continue;
359353 }
360354
361− const encoded = argsValueToString(parameterValue);
362−
363− if (parameter.location === "query") {
364− url.searchParams.set(parameter.name, encoded);
365− } else if (parameter.location === "header") {
366− headers[parameter.name] = encoded;
367− } else if (parameter.location === "cookie") {
368− cookieParts.push(`${parameter.name}=${encodeURIComponent(encoded)}`);
355+ const serialized = serializeOpenApiParameterValue(parameter, parameterValue);
356+
357+ if (serialized.kind === "query") {
358+ queryEntries.push(...serialized.entries);
359+ } else if (serialized.kind === "header") {
360+ headers[parameter.name] = serialized.value;
361+ } else if (serialized.kind === "cookie") {
362+ cookieParts.push(
363+ ...serialized.pairs.map((pair) =>
364+ `${pair.name}=${encodeURIComponent(pair.value)}`
365+ ),
366+ );
369367 }
370368 }
371369
@@ -387,20 +385,17 @@ const buildFetchRequest = (input: { · 15 unmodified lines
387385 });
388386 }
389387 } else {
390− body = JSON.stringify(
391− applyJsonBodyPlacements({
388+ const serializedBody = serializeOpenApiRequestBody({
389+ requestBody: input.payload.requestBody,
390+ body: applyJsonBodyPlacements({
392391 body: hasRequestBody(input.args) ? input.args.body : {},
393392 bodyValues,
394393 label: `${input.payload.method.toUpperCase()} ${input.payload.pathTemplate}`,
395394 }),
396− );
395+ });
397396
398− const preferredContentType = input.payload.requestBody.contentTypes[0];
399− if (preferredContentType) {
400− headers["content-type"] = preferredContentType;
401− } else if (!("content-type" in headers)) {
402− headers["content-type"] = "application/json";
403− }
397+ body = serializedBody.bodyText;
398+ headers["content-type"] = serializedBody.contentType;
404399 }
405400 }
406401
@@ -408,6 +403,7 @@ const buildFetchRequest = (input: { · 1 unmodified line
408403 url,
409404 queryParams: input.credentialPlacements.queryParams,
410405 });
406+ const urlWithQueryParams = withSerializedQueryEntries(urlWithAuth, queryEntries);
411407 const headersWithAuthCookies = applyCookiePlacementsToHeaders({
412408 headers,
413409 cookies: input.credentialPlacements.cookies,
@@ -418,7 +414,7 @@ const buildFetchRequest = (input: { · 4 unmodified lines
418414 }
419415
420416 return {
421− url: urlWithAuth,
417+ url: urlWithQueryParams,
422418 method: input.payload.method.toUpperCase(),
423419 headers: headersWithAuthCookies,
424420 body,
packages/codemode-openapi/src/openapi-types.ts+136−0
@@ -43,11 +43,45 @@ export const OpenApiToolParameterSchema = Schema.Struct({ · 42 unmodified lines
4343 name: Schema.String,
4444 location: OpenApiParameterLocationSchema,
4545 required: Schema.Boolean,
46+ style: Schema.optional(Schema.String),
47+ explode: Schema.optional(Schema.Boolean),
48+ allowReserved: Schema.optional(Schema.Boolean),
49+ content: Schema.optional(Schema.Array(Schema.suspend(() => OpenApiMediaContentSchema))),
50+});
51+
52+export const OpenApiMediaContentSchema = Schema.Struct({
53+ mediaType: Schema.String,
54+ schema: Schema.optional(Schema.Unknown),
55+ examples: Schema.optional(Schema.Array(Schema.suspend(() => OpenApiExampleSchema))),
56+});
57+
58+export const OpenApiHeaderSchema = Schema.Struct({
59+ name: Schema.String,
60+ description: Schema.optional(Schema.String),
61+ required: Schema.optional(Schema.Boolean),
62+ deprecated: Schema.optional(Schema.Boolean),
63+ schema: Schema.optional(Schema.Unknown),
64+ content: Schema.optional(Schema.Array(OpenApiMediaContentSchema)),
65+ style: Schema.optional(Schema.String),
66+ explode: Schema.optional(Schema.Boolean),
67+ examples: Schema.optional(Schema.Array(Schema.suspend(() => OpenApiExampleSchema))),
4668 });
4769
4870 export const OpenApiToolRequestBodySchema = Schema.Struct({
4971 required: Schema.Boolean,
5072 contentTypes: Schema.Array(Schema.String),
73+ contents: Schema.optional(Schema.Array(OpenApiMediaContentSchema)),
74+});
75+
76+export const OpenApiServerSchema = Schema.Struct({
77+ url: Schema.String,
78+ description: Schema.optional(Schema.String),
79+ variables: Schema.optional(
80+ Schema.Record({
81+ key: Schema.String,
82+ value: Schema.String,
83+ }),
84+ ),
5185 });
5286
5387 export const OpenApiInvocationPayloadSchema = Schema.Struct({
@@ -102,6 +136,16 @@ export const OpenApiResponseDocumentationSchema = Schema.Struct({ · 48 unmodified lines
102136 examples: Schema.optional(Schema.Array(OpenApiExampleSchema)),
103137 });
104138
139+export const OpenApiResponseVariantSchema = Schema.Struct({
140+ statusCode: Schema.String,
141+ description: Schema.optional(Schema.String),
142+ contentTypes: Schema.Array(Schema.String),
143+ schema: Schema.optional(Schema.Unknown),
144+ examples: Schema.optional(Schema.Array(OpenApiExampleSchema)),
145+ contents: Schema.optional(Schema.Array(OpenApiMediaContentSchema)),
146+ headers: Schema.optional(Schema.Array(OpenApiHeaderSchema)),
147+});
148+
105149 export const OpenApiToolDocumentationSchema = Schema.Struct({
106150 summary: Schema.optional(Schema.String),
107151 deprecated: Schema.optional(Schema.Boolean),
@@ -110,6 +154,81 @@ export const OpenApiToolDocumentationSchema = Schema.Struct({ · 2 unmodified lines
110154 response: Schema.optional(OpenApiResponseDocumentationSchema),
111155 });
112156
157+export type OpenApiSecurityRequirement =
158+ | {
159+ readonly kind: "none";
160+ }
161+ | {
162+ readonly kind: "scheme";
163+ readonly schemeName: string;
164+ readonly scopes?: readonly string[];
165+ }
166+ | {
167+ readonly kind: "allOf";
168+ readonly items: readonly OpenApiSecurityRequirement[];
169+ }
170+ | {
171+ readonly kind: "anyOf";
172+ readonly items: readonly OpenApiSecurityRequirement[];
173+ };
174+
175+export const OpenApiSecurityRequirementSchema = Schema.suspend(() =>
176+ Schema.Union(
177+ Schema.Struct({
178+ kind: Schema.Literal("none"),
179+ }),
180+ Schema.Struct({
181+ kind: Schema.Literal("scheme"),
182+ schemeName: Schema.String,
183+ scopes: Schema.optional(Schema.Array(Schema.String)),
184+ }),
185+ Schema.Struct({
186+ kind: Schema.Literal("allOf"),
187+ items: Schema.Array(OpenApiSecurityRequirementSchema),
188+ }),
189+ Schema.Struct({
190+ kind: Schema.Literal("anyOf"),
191+ items: Schema.Array(OpenApiSecurityRequirementSchema),
192+ }),
193+ )
194+) as Schema.Schema<OpenApiSecurityRequirement, OpenApiSecurityRequirement, never>;
195+
196+export const OpenApiSecuritySchemeTypeSchema = Schema.Literal(
197+ "apiKey",
198+ "http",
199+ "oauth2",
200+ "openIdConnect",
201+);
202+
203+export const OpenApiOAuthFlowSchema = Schema.Struct({
204+ authorizationUrl: Schema.optional(Schema.String),
205+ tokenUrl: Schema.optional(Schema.String),
206+ refreshUrl: Schema.optional(Schema.String),
207+ scopes: Schema.optional(
208+ Schema.Record({
209+ key: Schema.String,
210+ value: Schema.String,
211+ }),
212+ ),
213+});
214+
215+export const OpenApiSecuritySchemeSchema = Schema.Struct({
216+ schemeName: Schema.String,
217+ schemeType: OpenApiSecuritySchemeTypeSchema,
218+ description: Schema.optional(Schema.String),
219+ placementIn: Schema.optional(Schema.Literal("header", "query", "cookie")),
220+ placementName: Schema.optional(Schema.String),
221+ scheme: Schema.optional(Schema.String),
222+ bearerFormat: Schema.optional(Schema.String),
223+ openIdConnectUrl: Schema.optional(Schema.String),
224+ flows: Schema.optional(
225+ Schema.Record({
226+ key: Schema.String,
227+ value: OpenApiOAuthFlowSchema,
228+ }),
229+ ),
230+});
231+
113232 export const OpenApiToolProviderDataSchema = Schema.Struct({
114233 kind: Schema.Literal("openapi"),
115234 toolId: Schema.String,
@@ -124,6 +243,11 @@ export const OpenApiToolProviderDataSchema = Schema.Struct({ · 8 unmodified lines
124243 operationHash: Schema.String,
125244 invocation: OpenApiInvocationPayloadSchema,
126245 documentation: Schema.optional(OpenApiToolDocumentationSchema),
246+ responses: Schema.optional(Schema.Array(OpenApiResponseVariantSchema)),
247+ authRequirement: Schema.optional(OpenApiSecurityRequirementSchema),
248+ securitySchemes: Schema.optional(Schema.Array(OpenApiSecuritySchemeSchema)),
249+ documentServers: Schema.optional(Schema.Array(OpenApiServerSchema)),
250+ servers: Schema.optional(Schema.Array(OpenApiServerSchema)),
127251 });
128252
129253 export const OpenApiExtractedToolSchema = Schema.Struct({
@@ -138,6 +262,11 @@ export const OpenApiExtractedToolSchema = Schema.Struct({ · 8 unmodified lines
138262 operationHash: Schema.String,
139263 typing: Schema.optional(DiscoveryTypingPayloadSchema),
140264 documentation: Schema.optional(OpenApiToolDocumentationSchema),
265+ responses: Schema.optional(Schema.Array(OpenApiResponseVariantSchema)),
266+ authRequirement: Schema.optional(OpenApiSecurityRequirementSchema),
267+ securitySchemes: Schema.optional(Schema.Array(OpenApiSecuritySchemeSchema)),
268+ documentServers: Schema.optional(Schema.Array(OpenApiServerSchema)),
269+ servers: Schema.optional(Schema.Array(OpenApiServerSchema)),
141270 });
142271
143272 export const OpenApiToolManifestSchema = Schema.Struct({
@@ -155,7 +284,10 @@ export const OpenApiToolManifestSchema = Schema.Struct({ · 11 unmodified lines
155284 export type OpenApiHttpMethod = typeof OpenApiHttpMethodSchema.Type;
156285 export type OpenApiParameterLocation = typeof OpenApiParameterLocationSchema.Type;
157286 export type OpenApiToolParameter = typeof OpenApiToolParameterSchema.Type;
287+export type OpenApiMediaContent = typeof OpenApiMediaContentSchema.Type;
288+export type OpenApiHeader = typeof OpenApiHeaderSchema.Type;
158289 export type OpenApiToolRequestBody = typeof OpenApiToolRequestBodySchema.Type;
290+export type OpenApiServer = typeof OpenApiServerSchema.Type;
159291 export type OpenApiInvocationPayload = typeof OpenApiInvocationPayloadSchema.Type;
160292 export type DiscoveryTypingPayload = typeof DiscoveryTypingPayloadSchema.Type;
161293 export type OpenApiRefHintValue = typeof OpenApiRefHintValueSchema.Type;
@@ -164,7 +296,11 @@ export type OpenApiExample = typeof OpenApiExampleSchema.Type; · 2 unmodified lines
164296 export type OpenApiParameterDocumentation = typeof OpenApiParameterDocumentationSchema.Type;
165297 export type OpenApiRequestBodyDocumentation = typeof OpenApiRequestBodyDocumentationSchema.Type;
166298 export type OpenApiResponseDocumentation = typeof OpenApiResponseDocumentationSchema.Type;
299+export type OpenApiResponseVariant = typeof OpenApiResponseVariantSchema.Type;
167300 export type OpenApiToolDocumentation = typeof OpenApiToolDocumentationSchema.Type;
301+export type OpenApiOAuthFlow = typeof OpenApiOAuthFlowSchema.Type;
302+export type OpenApiSecuritySchemeType = typeof OpenApiSecuritySchemeTypeSchema.Type;
303+export type OpenApiSecurityScheme = typeof OpenApiSecuritySchemeSchema.Type;
168304 export type OpenApiToolProviderData = typeof OpenApiToolProviderDataSchema.Type;
169305 export type OpenApiExtractedTool = typeof OpenApiExtractedToolSchema.Type;
170306 export type OpenApiToolManifest = typeof OpenApiToolManifestSchema.Type;