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
17 openApiOutputTypeSignatureFromSchema,17 openApiOutputTypeSignatureFromSchema,
18 type OpenApiToolPresentation,18 type OpenApiToolPresentation,
19} from "./openapi-tool-presentation";19} from "./openapi-tool-presentation";
20export {
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";
20export {30export {
21 OpenApiToolInvocationError,31 OpenApiToolInvocationError,
22 createOpenApiToolFromDefinition,32 createOpenApiToolFromDefinition,
@@ -35,11 +45,19 @@ export { · 12 unmodified lines
35 OpenApiToolManifestSchema,45 OpenApiToolManifestSchema,
36 OpenApiToolParameterSchema,46 OpenApiToolParameterSchema,
37 OpenApiToolRequestBodySchema,47 OpenApiToolRequestBodySchema,
48 OpenApiMediaContentSchema,
49 OpenApiHeaderSchema,
50 OpenApiServerSchema,
38 OpenApiExampleSchema,51 OpenApiExampleSchema,
52 OpenApiResponseVariantSchema,
39 OpenApiParameterDocumentationSchema,53 OpenApiParameterDocumentationSchema,
40 OpenApiRequestBodyDocumentationSchema,54 OpenApiRequestBodyDocumentationSchema,
41 OpenApiResponseDocumentationSchema,55 OpenApiResponseDocumentationSchema,
42 OpenApiToolDocumentationSchema,56 OpenApiToolDocumentationSchema,
57 OpenApiSecurityRequirementSchema,
58 OpenApiSecuritySchemeTypeSchema,
59 OpenApiOAuthFlowSchema,
60 OpenApiSecuritySchemeSchema,
43 OpenApiToolProviderDataSchema,61 OpenApiToolProviderDataSchema,
44 OpenApiRefHintTableSchema,62 OpenApiRefHintTableSchema,
45 OpenApiRefHintValueSchema,63 OpenApiRefHintValueSchema,
@@ -58,6 +76,14 @@ export { · 12 unmodified lines
58 type OpenApiRefHintValue,76 type OpenApiRefHintValue,
59 type OpenApiToolParameter,77 type OpenApiToolParameter,
60 type OpenApiToolRequestBody,78 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,
61} from "./openapi-types";87} from "./openapi-types";
62export type {88export type {
63 OpenApiExample,89 OpenApiExample,
packages/codemode-openapi/src/openapi-definitions.ts+19−0
@@ -4,6 +4,10 @@ import { · 3 unmodified lines
4 DiscoveryTypingPayloadSchema,4 DiscoveryTypingPayloadSchema,
5 OpenApiHttpMethodSchema,5 OpenApiHttpMethodSchema,
6 OpenApiInvocationPayloadSchema,6 OpenApiInvocationPayloadSchema,
7 OpenApiResponseVariantSchema,
8 OpenApiSecurityRequirementSchema,
9 OpenApiSecuritySchemeSchema,
10 OpenApiServerSchema,
7 OpenApiToolDocumentationSchema,11 OpenApiToolDocumentationSchema,
8 OpenApiToolProviderDataSchema,12 OpenApiToolProviderDataSchema,
9 type OpenApiExtractedTool,13 type OpenApiExtractedTool,
@@ -143,6 +147,11 @@ export const OpenApiToolDefinitionSchema = Schema.Struct({ · 133 unmodified lines
143 operationHash: Schema.String,147 operationHash: Schema.String,
144 typing: Schema.optional(DiscoveryTypingPayloadSchema),148 typing: Schema.optional(DiscoveryTypingPayloadSchema),
145 documentation: Schema.optional(OpenApiToolDocumentationSchema),149 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)),
146});155});
147 156
148export type OpenApiToolDefinition = typeof OpenApiToolDefinitionSchema.Type;157export type OpenApiToolDefinition = typeof OpenApiToolDefinitionSchema.Type;
@@ -226,6 +235,11 @@ export const compileOpenApiToolDefinitions = ( · 77 unmodified lines
226 operationHash: tool.operationHash,235 operationHash: tool.operationHash,
227 typing: tool.typing,236 typing: tool.typing,
228 documentation: tool.documentation,237 documentation: tool.documentation,
238 responses: tool.responses,
239 authRequirement: tool.authRequirement,
240 securitySchemes: tool.securitySchemes,
241 documentServers: tool.documentServers,
242 servers: tool.servers,
229 } satisfies Omit<OpenApiToolDefinition, "toolId">;243 } satisfies Omit<OpenApiToolDefinition, "toolId">;
230 });244 });
231 245
@@ -252,4 +266,9 @@ export const openApiProviderDataFromDefinition = ( · 20 unmodified lines
252 operationHash: definition.operationHash,266 operationHash: definition.operationHash,
253 invocation: definition.invocation,267 invocation: definition.invocation,
254 documentation: definition.documentation,268 documentation: definition.documentation,
269 responses: definition.responses,
270 authRequirement: definition.authRequirement,
271 securitySchemes: definition.securitySchemes,
272 documentServers: definition.documentServers,
273 servers: definition.servers,
255 } satisfies typeof OpenApiToolProviderDataSchema.Type);274 } 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
9import {9import {
10 OpenApiToolManifestSchema,10 OpenApiToolManifestSchema,
11 type OpenApiExtractedTool,11 type OpenApiExtractedTool,
12 type OpenApiHeader,
13 type OpenApiSecurityRequirement,
14 type OpenApiServer,
12 type OpenApiJsonObject,15 type OpenApiJsonObject,
13 type OpenApiSpecInput,16 type OpenApiSpecInput,
14 type OpenApiToolManifest,17 type OpenApiToolManifest,
@@ -67,6 +70,58 @@ const asObject = (value: unknown): Record<string, unknown> => · 52 unmodified lines
67 ? (value as Record<string, unknown>)70 ? (value as Record<string, unknown>)
68 : {};71 : {};
69 72
73const asArray = (value: unknown): ReadonlyArray<unknown> =>
74 Array.isArray(value) ? value : [];
75
76const asTrimmedString = (value: unknown): string | undefined =>
77 typeof value === "string" && value.trim().length > 0
78 ? value.trim()
79 : undefined;
80
81const 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
97const stableJsonStringify = (value: unknown): string =>
98 JSON.stringify(stableJsonValue(value));
99
100const 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
70const resolvePointerSegment = (segment: string): string =>125const resolvePointerSegment = (segment: string): string =>
71 segment.replaceAll("~1", "/").replaceAll("~0", "~");126 segment.replaceAll("~1", "/").replaceAll("~0", "~");
72 127
@@ -107,24 +162,264 @@ const resolveLocalRef = ( · 34 unmodified lines
107 : resolvedObject;162 : resolvedObject;
108};163};
109 164
165const 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
177const 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
110const contentSchemaFromOperationContent = (190const contentSchemaFromOperationContent = (
111 content: unknown,191 content: unknown,
112): unknown | undefined => {192): unknown | undefined => {
113 const entries = Object.entries(asObject(content));193 return preferredContentEntry(content)?.[1].schema;
114 const preferredEntry = entries.find(([mediaType]) => mediaType === "application/json")194};
115 ?? entries.find(([mediaType]) => mediaType.toLowerCase().includes("+json"))195
116 ?? entries.find(([mediaType]) => mediaType.toLowerCase().includes("json"));196const 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 });
117 220
118 return preferredEntry ? asObject(preferredEntry[1]).schema : undefined;221 return entries;
119};222};
120 223
121const requestBodySchemaForTool = (224const 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
266const examplesFromSchema = (
267 schema: unknown,
268): Array<{
269 valueJson: string;
270 mediaType?: string;
271 label?: string;
272}> => examplesFromValue(schema);
273
274const 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
293const 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
326const 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
336const 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
366const pathItemForTool = (
122 document: OpenApiJsonObject,367 document: OpenApiJsonObject,
123 tool: OpenApiExtractedTool,368 tool: OpenApiExtractedTool,
124): unknown | undefined => {369): Record<string, unknown> =>
125 const operation = asObject(370 asObject(asObject(document.paths)[tool.path]);
371
372const parameterKey = (input: {
373 location: string;
374 name: string;
375}): string => `${input.location}:${input.name}`;
376
377const 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
410const operationForTool = (
411 document: OpenApiJsonObject,
412 tool: OpenApiExtractedTool,
413): Record<string, unknown> =>
414 asObject(
126 asObject(asObject(document.paths)[tool.path])[tool.method],415 asObject(asObject(document.paths)[tool.path])[tool.method],
127 );416 );
417
418const requestBodySchemaForTool = (
419 document: OpenApiJsonObject,
420 tool: OpenApiExtractedTool,
421): unknown | undefined => {
422 const operation = operationForTool(document, tool);
128 if (Object.keys(operation).length === 0) {423 if (Object.keys(operation).length === 0) {
129 return undefined;424 return undefined;
130 }425 }
@@ -133,13 +428,38 @@ const requestBodySchemaForTool = ( · 2 unmodified lines
133 return contentSchemaFromOperationContent(asObject(requestBody).content);428 return contentSchemaFromOperationContent(asObject(requestBody).content);
134};429};
135 430
431const 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
136const responseSchemaForTool = (458const responseSchemaForTool = (
137 document: OpenApiJsonObject,459 document: OpenApiJsonObject,
138 tool: OpenApiExtractedTool,460 tool: OpenApiExtractedTool,
139): unknown | undefined => {461): unknown | undefined => {
140 const operation = asObject(462 const operation = operationForTool(document, tool);
141 asObject(asObject(document.paths)[tool.path])[tool.method],
142 );
143 if (Object.keys(operation).length === 0) {463 if (Object.keys(operation).length === 0) {
144 return undefined;464 return undefined;
145 }465 }
@@ -161,29 +481,388 @@ const responseSchemaForTool = ( · 15 unmodified lines
161 return undefined;481 return undefined;
162};482};
163 483
484const 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
526const documentServersFromDocument = (
527 document: OpenApiJsonObject,
528): OpenApiExtractedTool["documentServers"] => {
529 const servers = serversFromValue(document.servers);
530 return servers.length > 0 ? servers : undefined;
531};
532
533const 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
551const 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
605const 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
621const 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
643const 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
680const 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
749const 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
164const enrichManifestFromDocument = (782const enrichManifestFromDocument = (
165 document: OpenApiJsonObject,783 document: OpenApiJsonObject,
166 manifest: OpenApiToolManifest,784 manifest: OpenApiToolManifest,
167): OpenApiToolManifest => ({785): OpenApiToolManifest => {
168 ...manifest,786 const extraRefKeys = new Set<string>();
169 tools: manifest.tools.map((tool) => {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);
170 const inputSchema = tool.typing?.inputSchema ?? requestBodySchemaForTool(document, tool);806 const inputSchema = tool.typing?.inputSchema ?? requestBodySchemaForTool(document, tool);
171 const outputSchema = tool.typing?.outputSchema ?? responseSchemaForTool(document, tool);807 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 }
172 822
173 if (inputSchema === undefined && outputSchema === undefined) {823 for (const response of responses ?? []) {
174 return tool;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 }
175 }834 }
176 835
177 return {836 return {
178 ...tool,837 ...tool,
179 typing: {838 invocation: {
180 ...(tool.typing ?? {}),839 ...tool.invocation,
181 ...(inputSchema !== undefined ? { inputSchema } : {}),840 parameters: invocationParameters,
182 ...(outputSchema !== undefined ? { outputSchema } : {}),841 requestBody,
183 },842 },
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 } : {}),
184 };857 };
185 }),858 });
186});859
860 return {
861 ...manifest,
862 tools,
863 refHintTable: mergeRefHintTable(document, manifest, extraRefKeys),
864 };
865};
187 866
188export const extractOpenApiManifest = (867export const extractOpenApiManifest = (
189 sourceName: string,868 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 @@
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 SerializedOpenApiQueryEntry = {
22 name: string;
23 value: string;
24 allowReserved?: boolean;
25};
26
27export 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
44const isRecord = (value: unknown): value is Record<string, unknown> =>
45 value !== null && typeof value === "object" && !Array.isArray(value);
46
47const 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
71const objectEntries = (value: unknown): Array<[string, unknown]> =>
72 Object.entries(isRecord(value) ? value : {})
73 .sort(([left], [right]) => left.localeCompare(right));
74
75const 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
86const defaultExplode = (location: OpenApiSerializableLocation, style: string): boolean =>
87 location === "query" || location === "cookie"
88 ? style === "form"
89 : false;
90
91const selectedStyle = (parameter: OpenApiSerializableParameter): string =>
92 parameter.style ?? defaultStyle(parameter.location);
93
94const selectedExplode = (parameter: OpenApiSerializableParameter): boolean =>
95 parameter.explode ?? defaultExplode(parameter.location, selectedStyle(parameter));
96
97const encodePathPart = (value: unknown): string =>
98 encodeURIComponent(primitiveString(value));
99
100const 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
127const 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
145const 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
173const 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
274const 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
311const 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
369const 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
462export 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
478export 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
502export 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
3import { describe, expect, it } from "@effect/vitest";3import { describe, expect, it } from "@effect/vitest";
4import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
5 5
6import type { OpenApiToolDefinition } from "./openapi-definitions";
6import { compileOpenApiToolDefinitions } from "./openapi-definitions";7import { compileOpenApiToolDefinitions } from "./openapi-definitions";
7import { extractOpenApiManifest } from "./openapi-extraction";8import { extractOpenApiManifest } from "./openapi-extraction";
8import { buildOpenApiToolPresentation } from "./openapi-tool-presentation";9import { buildOpenApiToolPresentation } from "./openapi-tool-presentation";
@@ -14,6 +15,188 @@ const readFixture = (name: string): string => · 5 unmodified lines
14 );15 );
15 16
16describe("buildOpenApiToolPresentation", () => {17describe("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
17 it.effect(200 it.effect(
18 "resolves request and response schemas from ref hints for the real Neon OpenAPI spec",201 "resolves request and response schemas from ref hints for the real Neon OpenAPI spec",
19 () =>202 () =>
@@ -104,4 +287,65 @@ describe("buildOpenApiToolPresentation", () => { · 84 unmodified lines
104 }),287 }),
105 120_000,288 120_000,
106 );289 );
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 );
107});351});
packages/codemode-openapi/src/openapi-tool-presentation.ts+128−18
@@ -3,8 +3,10 @@ import { typeSignatureFromSchema } from "@executor/codemode-core"; · 2 unmodified lines
3 3
4import type {4import type {
5 OpenApiExample,5 OpenApiExample,
6 OpenApiMediaContent,
6 OpenApiInvocationPayload,7 OpenApiInvocationPayload,
7 OpenApiRefHintTable,8 OpenApiRefHintTable,
9 OpenApiResponseVariant,
8 OpenApiToolDocumentation,10 OpenApiToolDocumentation,
9 OpenApiToolProviderData,11 OpenApiToolProviderData,
10} from "./openapi-types";12} from "./openapi-types";
@@ -51,8 +53,74 @@ const firstExample = ( · 40 unmodified lines
51 examples: ReadonlyArray<OpenApiExample> | undefined,53 examples: ReadonlyArray<OpenApiExample> | undefined,
52): OpenApiExample | undefined => examples?.[0];54): OpenApiExample | undefined => examples?.[0];
53 55
56const 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
65const 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
85const 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
101const 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
54const callInputSchemaFromInvocation = (input: {120const callInputSchemaFromInvocation = (input: {
55 invocation: OpenApiInvocationPayload;121 invocation: OpenApiInvocationPayload;
122 documentation?: OpenApiToolDocumentation;
123 parameterSourceSchema?: unknown;
56 requestBodySchema?: unknown;124 requestBodySchema?: unknown;
57}): Record<string, unknown> | undefined => {125}): Record<string, unknown> | undefined => {
58 const invocation = input.invocation;126 const invocation = input.invocation;
@@ -60,18 +128,33 @@ const callInputSchemaFromInvocation = (input: { · 1 unmodified line
60 const required: string[] = [];128 const required: string[] = [];
61 129
62 for (const parameter of invocation.parameters) {130 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) ?? {
64 type: "string",139 type: "string",
65 };140 };
141
142 properties[parameter.name] = withDescription(
143 parameterSchema,
144 matchingDocs?.description,
145 );
66 if (parameter.required) {146 if (parameter.required) {
67 required.push(parameter.name);147 required.push(parameter.name);
68 }148 }
69 }149 }
70 150
71 if (invocation.requestBody) {151 if (invocation.requestBody) {
72 properties.body = input.requestBodySchema ?? {152 properties.body = withDescription(
73 type: "object",153 input.requestBodySchema ?? preferredContentSchema(invocation.requestBody.contents) ?? {
74 };154 type: "object",
155 },
156 input.documentation?.requestBody?.description,
157 );
75 if (invocation.requestBody.required) {158 if (invocation.requestBody.required) {
76 required.push("body");159 required.push("body");
77 }160 }
@@ -130,23 +213,22 @@ const resolvePresentationSchemas = (input: { · 52 unmodified lines
130 input.refHintTable,213 input.refHintTable,
131 );214 );
132 const requestBodySchema =215 const requestBodySchema =
133 resolvedTyping.inputSchema216 schemaProperty(resolvedTyping.inputSchema, "body")
217 ?? schemaProperty(resolvedTyping.inputSchema, "input")
134 ?? (resolvedRequestBodySchema !== undefined && resolvedRequestBodySchema !== null218 ?? (resolvedRequestBodySchema !== undefined && resolvedRequestBodySchema !== null
135 ? resolvedRequestBodySchema219 ? resolvedRequestBodySchema
136 : undefined);220 : undefined);
137 221
138 const inputSchema =222 const inputSchema = callInputSchemaFromInvocation({
139 input.definition.invocation.requestBody !== null223 invocation: input.definition.invocation,
140 ? callInputSchemaFromInvocation({224 documentation: input.definition.documentation,
141 invocation: input.definition.invocation,225 parameterSourceSchema: resolvedTyping.inputSchema,
142 ...(requestBodySchema !== undefined226 ...(requestBodySchema !== undefined
143 ? { requestBodySchema }227 ? { requestBodySchema }
144 : {}),228 : {}),
145 })229 })
146 : resolvedTyping.inputSchema230 ?? resolvedTyping.inputSchema
147 ?? callInputSchemaFromInvocation({231 ?? undefined;
148 invocation: input.definition.invocation,
149 });
150 const outputSchema =232 const outputSchema =
151 resolvedTyping.outputSchema233 resolvedTyping.outputSchema
152 ?? resolveSchemaWithRefHints(234 ?? resolveSchemaWithRefHints(
@@ -160,6 +242,27 @@ const resolvePresentationSchemas = (input: { · 7 unmodified lines
160 };242 };
161};243};
162 244
245const 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
163const buildExampleInput = (266const buildExampleInput = (
164 documentation: OpenApiToolDocumentation | undefined,267 documentation: OpenApiToolDocumentation | undefined,
165): Record<string, unknown> | undefined => {268): Record<string, unknown> | undefined => {
@@ -208,6 +311,10 @@ export const buildOpenApiToolPresentation = (input: { · 42 unmodified lines
208 refHintTable?: Readonly<OpenApiRefHintTable>;311 refHintTable?: Readonly<OpenApiRefHintTable>;
209}): OpenApiToolPresentation => {312}): OpenApiToolPresentation => {
210 const { inputSchema, outputSchema } = resolvePresentationSchemas(input);313 const { inputSchema, outputSchema } = resolvePresentationSchemas(input);
314 const responses = resolveResponseVariantsWithRefHints({
315 variants: input.definition.responses,
316 refHintTable: input.refHintTable,
317 });
211 const exampleInput = buildExampleInput(input.definition.documentation);318 const exampleInput = buildExampleInput(input.definition.documentation);
212 const exampleOutput = buildExampleOutput(input.definition.documentation);319 const exampleOutput = buildExampleOutput(input.definition.documentation);
213 320
@@ -218,6 +325,9 @@ export const buildOpenApiToolPresentation = (input: { · 4 unmodified lines
218 ...(outputSchema !== undefined ? { outputSchema } : {}),325 ...(outputSchema !== undefined ? { outputSchema } : {}),
219 ...(exampleInput !== undefined ? { exampleInput } : {}),326 ...(exampleInput !== undefined ? { exampleInput } : {}),
220 ...(exampleOutput !== undefined ? { exampleOutput } : {}),327 ...(exampleOutput !== undefined ? { exampleOutput } : {}),
221 providerData: openApiProviderDataFromDefinition(input.definition),328 providerData: {
329 ...openApiProviderDataFromDefinition(input.definition),
330 ...(responses ? { responses } : {}),
331 } satisfies OpenApiToolProviderData,
222 };332 };
223};333};
packages/codemode-openapi/src/openapi-tools.test.ts+532−0
@@ -296,6 +296,315 @@ describe("openapi-tools", () => { · 295 unmodified lines
296 }),296 }),
297 );297 );
298 298
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
299 it.scoped("accepts OpenApi.fromApi generated specs", () =>608 it.scoped("accepts OpenApi.fromApi generated specs", () =>
300 Effect.gen(function* () {609 Effect.gen(function* () {
301 const server = yield* makeTestServer;610 const server = yield* makeTestServer;
@@ -396,6 +705,229 @@ describe("openapi-tools", () => { · 94 unmodified lines
396 }),705 }),
397 );706 );
398 707
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
399 it.effect("throws on non-2xx OpenAPI responses", () =>931 it.effect("throws on non-2xx OpenAPI responses", () =>
400 Effect.gen(function* () {932 Effect.gen(function* () {
401 const manifest = yield* extractOpenApiManifest("generated", generatedOpenApiSpec);933 const manifest = yield* extractOpenApiManifest("generated", generatedOpenApiSpec);
packages/codemode-openapi/src/openapi-tools.ts+32−36
@@ -22,6 +22,12 @@ import { · 21 unmodified lines
22 compileOpenApiToolDefinitions,22 compileOpenApiToolDefinitions,
23 type OpenApiToolDefinition,23 type OpenApiToolDefinition,
24} from "./openapi-definitions";24} from "./openapi-definitions";
25import {
26 serializeOpenApiParameterValue,
27 serializeOpenApiRequestBody,
28 withSerializedQueryEntries,
29 type SerializedOpenApiQueryEntry,
30} from "./openapi-http-serialization";
25import { buildOpenApiToolPresentation } from "./openapi-tool-presentation";31import { buildOpenApiToolPresentation } from "./openapi-tool-presentation";
26import { resolveSchemaWithRefHints } from "./openapi-schema-refs";32import { resolveSchemaWithRefHints } from "./openapi-schema-refs";
27import {33import {
@@ -67,22 +73,6 @@ const parameterContainerKeys: Record< · 39 unmodified lines
67 cookie: ["cookies", "cookie"],73 cookie: ["cookies", "cookie"],
68};74};
69 75
70const 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
86const readParameterValue = (76const readParameterValue = (
87 args: OpenApiToolArgs,77 args: OpenApiToolArgs,
88 parameter: OpenApiToolParameter,78 parameter: OpenApiToolParameter,
@@ -136,9 +126,12 @@ const replacePathTemplate = ( · 47 unmodified lines
136 continue;126 continue;
137 }127 }
138 128
129 const serialized = serializeOpenApiParameterValue(parameter, parameterValue);
139 resolvedPath = resolvedPath.replaceAll(130 resolvedPath = resolvedPath.replaceAll(
140 `{${parameter.name}}`,131 `{${parameter.name}}`,
141 encodeURIComponent(String(parameterValue)),132 serialized.kind === "path"
133 ? serialized.value
134 : encodeURIComponent(String(parameterValue)),
142 );135 );
143 }136 }
144 137
@@ -324,7 +317,7 @@ const buildFetchRequest = (input: { · 179 unmodified lines
324 defaultHeaders: Readonly<Record<string, string>>;317 defaultHeaders: Readonly<Record<string, string>>;
325 credentialPlacements: HttpRequestPlacements;318 credentialPlacements: HttpRequestPlacements;
326}): {319}): {
327 url: URL;320 url: string;
328 method: string;321 method: string;
329 headers: Record<string, string>;322 headers: Record<string, string>;
330 body?: string;323 body?: string;
@@ -340,6 +333,7 @@ const buildFetchRequest = (input: { · 9 unmodified lines
340 ...input.defaultHeaders,333 ...input.defaultHeaders,
341 };334 };
342 const cookieParts: Array<string> = [];335 const cookieParts: Array<string> = [];
336 const queryEntries: SerializedOpenApiQueryEntry[] = [];
343 337
344 for (const parameter of input.payload.parameters) {338 for (const parameter of input.payload.parameters) {
345 if (parameter.location === "path") {339 if (parameter.location === "path") {
@@ -358,14 +352,18 @@ const buildFetchRequest = (input: { · 12 unmodified lines
358 continue;352 continue;
359 }353 }
360 354
361 const encoded = argsValueToString(parameterValue);355 const serialized = serializeOpenApiParameterValue(parameter, parameterValue);
362 356
363 if (parameter.location === "query") {357 if (serialized.kind === "query") {
364 url.searchParams.set(parameter.name, encoded);358 queryEntries.push(...serialized.entries);
365 } else if (parameter.location === "header") {359 } else if (serialized.kind === "header") {
366 headers[parameter.name] = encoded;360 headers[parameter.name] = serialized.value;
367 } else if (parameter.location === "cookie") {361 } else if (serialized.kind === "cookie") {
368 cookieParts.push(`${parameter.name}=${encodeURIComponent(encoded)}`);362 cookieParts.push(
363 ...serialized.pairs.map((pair) =>
364 `${pair.name}=${encodeURIComponent(pair.value)}`
365 ),
366 );
369 }367 }
370 }368 }
371 369
@@ -387,20 +385,17 @@ const buildFetchRequest = (input: { · 15 unmodified lines
387 });385 });
388 }386 }
389 } else {387 } else {
390 body = JSON.stringify(388 const serializedBody = serializeOpenApiRequestBody({
391 applyJsonBodyPlacements({389 requestBody: input.payload.requestBody,
390 body: applyJsonBodyPlacements({
392 body: hasRequestBody(input.args) ? input.args.body : {},391 body: hasRequestBody(input.args) ? input.args.body : {},
393 bodyValues,392 bodyValues,
394 label: `${input.payload.method.toUpperCase()} ${input.payload.pathTemplate}`,393 label: `${input.payload.method.toUpperCase()} ${input.payload.pathTemplate}`,
395 }),394 }),
396 );395 });
397 396
398 const preferredContentType = input.payload.requestBody.contentTypes[0];397 body = serializedBody.bodyText;
399 if (preferredContentType) {398 headers["content-type"] = serializedBody.contentType;
400 headers["content-type"] = preferredContentType;
401 } else if (!("content-type" in headers)) {
402 headers["content-type"] = "application/json";
403 }
404 }399 }
405 }400 }
406 401
@@ -408,6 +403,7 @@ const buildFetchRequest = (input: { · 1 unmodified line
408 url,403 url,
409 queryParams: input.credentialPlacements.queryParams,404 queryParams: input.credentialPlacements.queryParams,
410 });405 });
406 const urlWithQueryParams = withSerializedQueryEntries(urlWithAuth, queryEntries);
411 const headersWithAuthCookies = applyCookiePlacementsToHeaders({407 const headersWithAuthCookies = applyCookiePlacementsToHeaders({
412 headers,408 headers,
413 cookies: input.credentialPlacements.cookies,409 cookies: input.credentialPlacements.cookies,
@@ -418,7 +414,7 @@ const buildFetchRequest = (input: { · 4 unmodified lines
418 }414 }
419 415
420 return {416 return {
421 url: urlWithAuth,417 url: urlWithQueryParams,
422 method: input.payload.method.toUpperCase(),418 method: input.payload.method.toUpperCase(),
423 headers: headersWithAuthCookies,419 headers: headersWithAuthCookies,
424 body,420 body,
packages/codemode-openapi/src/openapi-types.ts+136−0
@@ -43,11 +43,45 @@ export const OpenApiToolParameterSchema = Schema.Struct({ · 42 unmodified lines
43 name: Schema.String,43 name: Schema.String,
44 location: OpenApiParameterLocationSchema,44 location: OpenApiParameterLocationSchema,
45 required: Schema.Boolean,45 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
52export 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
58export 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))),
46});68});
47 69
48export const OpenApiToolRequestBodySchema = Schema.Struct({70export const OpenApiToolRequestBodySchema = Schema.Struct({
49 required: Schema.Boolean,71 required: Schema.Boolean,
50 contentTypes: Schema.Array(Schema.String),72 contentTypes: Schema.Array(Schema.String),
73 contents: Schema.optional(Schema.Array(OpenApiMediaContentSchema)),
74});
75
76export 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 ),
51});85});
52 86
53export const OpenApiInvocationPayloadSchema = Schema.Struct({87export const OpenApiInvocationPayloadSchema = Schema.Struct({
@@ -102,6 +136,16 @@ export const OpenApiResponseDocumentationSchema = Schema.Struct({ · 48 unmodified lines
102 examples: Schema.optional(Schema.Array(OpenApiExampleSchema)),136 examples: Schema.optional(Schema.Array(OpenApiExampleSchema)),
103});137});
104 138
139export 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
105export const OpenApiToolDocumentationSchema = Schema.Struct({149export const OpenApiToolDocumentationSchema = Schema.Struct({
106 summary: Schema.optional(Schema.String),150 summary: Schema.optional(Schema.String),
107 deprecated: Schema.optional(Schema.Boolean),151 deprecated: Schema.optional(Schema.Boolean),
@@ -110,6 +154,81 @@ export const OpenApiToolDocumentationSchema = Schema.Struct({ · 2 unmodified lines
110 response: Schema.optional(OpenApiResponseDocumentationSchema),154 response: Schema.optional(OpenApiResponseDocumentationSchema),
111});155});
112 156
157export 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
175export 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
196export const OpenApiSecuritySchemeTypeSchema = Schema.Literal(
197 "apiKey",
198 "http",
199 "oauth2",
200 "openIdConnect",
201);
202
203export 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
215export 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
113export const OpenApiToolProviderDataSchema = Schema.Struct({232export const OpenApiToolProviderDataSchema = Schema.Struct({
114 kind: Schema.Literal("openapi"),233 kind: Schema.Literal("openapi"),
115 toolId: Schema.String,234 toolId: Schema.String,
@@ -124,6 +243,11 @@ export const OpenApiToolProviderDataSchema = Schema.Struct({ · 8 unmodified lines
124 operationHash: Schema.String,243 operationHash: Schema.String,
125 invocation: OpenApiInvocationPayloadSchema,244 invocation: OpenApiInvocationPayloadSchema,
126 documentation: Schema.optional(OpenApiToolDocumentationSchema),245 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)),
127});251});
128 252
129export const OpenApiExtractedToolSchema = Schema.Struct({253export const OpenApiExtractedToolSchema = Schema.Struct({
@@ -138,6 +262,11 @@ export const OpenApiExtractedToolSchema = Schema.Struct({ · 8 unmodified lines
138 operationHash: Schema.String,262 operationHash: Schema.String,
139 typing: Schema.optional(DiscoveryTypingPayloadSchema),263 typing: Schema.optional(DiscoveryTypingPayloadSchema),
140 documentation: Schema.optional(OpenApiToolDocumentationSchema),264 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)),
141});270});
142 271
143export const OpenApiToolManifestSchema = Schema.Struct({272export const OpenApiToolManifestSchema = Schema.Struct({
@@ -155,7 +284,10 @@ export const OpenApiToolManifestSchema = Schema.Struct({ · 11 unmodified lines
155export type OpenApiHttpMethod = typeof OpenApiHttpMethodSchema.Type;284export type OpenApiHttpMethod = typeof OpenApiHttpMethodSchema.Type;
156export type OpenApiParameterLocation = typeof OpenApiParameterLocationSchema.Type;285export type OpenApiParameterLocation = typeof OpenApiParameterLocationSchema.Type;
157export type OpenApiToolParameter = typeof OpenApiToolParameterSchema.Type;286export type OpenApiToolParameter = typeof OpenApiToolParameterSchema.Type;
287export type OpenApiMediaContent = typeof OpenApiMediaContentSchema.Type;
288export type OpenApiHeader = typeof OpenApiHeaderSchema.Type;
158export type OpenApiToolRequestBody = typeof OpenApiToolRequestBodySchema.Type;289export type OpenApiToolRequestBody = typeof OpenApiToolRequestBodySchema.Type;
290export type OpenApiServer = typeof OpenApiServerSchema.Type;
159export type OpenApiInvocationPayload = typeof OpenApiInvocationPayloadSchema.Type;291export type OpenApiInvocationPayload = typeof OpenApiInvocationPayloadSchema.Type;
160export type DiscoveryTypingPayload = typeof DiscoveryTypingPayloadSchema.Type;292export type DiscoveryTypingPayload = typeof DiscoveryTypingPayloadSchema.Type;
161export type OpenApiRefHintValue = typeof OpenApiRefHintValueSchema.Type;293export type OpenApiRefHintValue = typeof OpenApiRefHintValueSchema.Type;
@@ -164,7 +296,11 @@ export type OpenApiExample = typeof OpenApiExampleSchema.Type; · 2 unmodified lines
164export type OpenApiParameterDocumentation = typeof OpenApiParameterDocumentationSchema.Type;296export type OpenApiParameterDocumentation = typeof OpenApiParameterDocumentationSchema.Type;
165export type OpenApiRequestBodyDocumentation = typeof OpenApiRequestBodyDocumentationSchema.Type;297export type OpenApiRequestBodyDocumentation = typeof OpenApiRequestBodyDocumentationSchema.Type;
166export type OpenApiResponseDocumentation = typeof OpenApiResponseDocumentationSchema.Type;298export type OpenApiResponseDocumentation = typeof OpenApiResponseDocumentationSchema.Type;
299export type OpenApiResponseVariant = typeof OpenApiResponseVariantSchema.Type;
167export type OpenApiToolDocumentation = typeof OpenApiToolDocumentationSchema.Type;300export type OpenApiToolDocumentation = typeof OpenApiToolDocumentationSchema.Type;
301export type OpenApiOAuthFlow = typeof OpenApiOAuthFlowSchema.Type;
302export type OpenApiSecuritySchemeType = typeof OpenApiSecuritySchemeTypeSchema.Type;
303export type OpenApiSecurityScheme = typeof OpenApiSecuritySchemeSchema.Type;
168export type OpenApiToolProviderData = typeof OpenApiToolProviderDataSchema.Type;304export type OpenApiToolProviderData = typeof OpenApiToolProviderDataSchema.Type;
169export type OpenApiExtractedTool = typeof OpenApiExtractedToolSchema.Type;305export type OpenApiExtractedTool = typeof OpenApiExtractedToolSchema.Type;
170export type OpenApiToolManifest = typeof OpenApiToolManifestSchema.Type;306export type OpenApiToolManifest = typeof OpenApiToolManifestSchema.Type;