Showing the changes to packages/codemode-openapi/src (10 of 30 files). Show all
Unified · Split
| @@ -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"; |
| 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"; | ||
| 20 | export { | 30 | export { |
| 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"; |
| 62 | export type { | 88 | export type { |
| 63 | OpenApiExample, | 89 | OpenApiExample, |
| @@ -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 | ||
| 148 | export type OpenApiToolDefinition = typeof OpenApiToolDefinitionSchema.Type; | 157 | export 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); |
| @@ -9,6 +9,9 @@ import { extractOpenApiManifestJsonWithWasm } from "./openapi-extractor-wasm"; · 8 unmodified lines | |||
| 9 | import { | 9 | import { |
| 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 | ||
| 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 | |||
| 70 | const resolvePointerSegment = (segment: string): string => | 125 | const 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 | ||
| 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 | |||
| 110 | const contentSchemaFromOperationContent = ( | 190 | const 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")); | 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 | }); | ||
| 117 | 220 | ||
| 118 | return preferredEntry ? asObject(preferredEntry[1]).schema : undefined; | 221 | return entries; |
| 119 | }; | 222 | }; |
| 120 | 223 | ||
| 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 = ( | ||
| 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 | |||
| 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( | ||
| 126 | asObject(asObject(document.paths)[tool.path])[tool.method], | 415 | asObject(asObject(document.paths)[tool.path])[tool.method], |
| 127 | ); | 416 | ); |
| 417 | |||
| 418 | const 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 | ||
| 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 | |||
| 136 | const responseSchemaForTool = ( | 458 | const 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 | ||
| 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 | |||
| 164 | const enrichManifestFromDocument = ( | 782 | const 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 | ||
| 188 | export const extractOpenApiManifest = ( | 867 | export const extractOpenApiManifest = ( |
| 189 | sourceName: string, | 868 | sourceName: string, |
Binary file not shown.
| @@ -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 | }; | ||
| @@ -3,6 +3,7 @@ import { readFileSync } from "node:fs"; · 2 unmodified lines | |||
| 3 | import { describe, expect, it } from "@effect/vitest"; | 3 | import { describe, expect, it } from "@effect/vitest"; |
| 4 | import * as Effect from "effect/Effect"; | 4 | import * as Effect from "effect/Effect"; |
| 5 | 5 | ||
| 6 | import type { OpenApiToolDefinition } from "./openapi-definitions"; | ||
| 6 | import { compileOpenApiToolDefinitions } from "./openapi-definitions"; | 7 | import { compileOpenApiToolDefinitions } from "./openapi-definitions"; |
| 7 | import { extractOpenApiManifest } from "./openapi-extraction"; | 8 | import { extractOpenApiManifest } from "./openapi-extraction"; |
| 8 | import { buildOpenApiToolPresentation } from "./openapi-tool-presentation"; | 9 | import { buildOpenApiToolPresentation } from "./openapi-tool-presentation"; |
| @@ -14,6 +15,188 @@ const readFixture = (name: string): string => · 5 unmodified lines | |||
| 14 | ); | 15 | ); |
| 15 | 16 | ||
| 16 | describe("buildOpenApiToolPresentation", () => { | 17 | 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 | |||
| 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 | }); |
| @@ -3,8 +3,10 @@ import { typeSignatureFromSchema } from "@executor/codemode-core"; · 2 unmodified lines | |||
| 3 | 3 | ||
| 4 | import type { | 4 | import 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 | ||
| 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 | |||
| 54 | const callInputSchemaFromInvocation = (input: { | 120 | const 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.inputSchema | 216 | schemaProperty(resolvedTyping.inputSchema, "body") |
| 217 | ?? schemaProperty(resolvedTyping.inputSchema, "input") | ||
| 134 | ?? (resolvedRequestBodySchema !== undefined && resolvedRequestBodySchema !== null | 218 | ?? (resolvedRequestBodySchema !== undefined && resolvedRequestBodySchema !== null |
| 135 | ? resolvedRequestBodySchema | 219 | ? resolvedRequestBodySchema |
| 136 | : undefined); | 220 | : undefined); |
| 137 | 221 | ||
| 138 | const inputSchema = | 222 | const inputSchema = callInputSchemaFromInvocation({ |
| 139 | input.definition.invocation.requestBody !== null | 223 | invocation: input.definition.invocation, |
| 140 | ? callInputSchemaFromInvocation({ | 224 | documentation: input.definition.documentation, |
| 141 | invocation: input.definition.invocation, | 225 | parameterSourceSchema: resolvedTyping.inputSchema, |
| 142 | ...(requestBodySchema !== undefined | 226 | ...(requestBodySchema !== undefined |
| 143 | ? { requestBodySchema } | 227 | ? { requestBodySchema } |
| 144 | : {}), | 228 | : {}), |
| 145 | }) | 229 | }) |
| 146 | : resolvedTyping.inputSchema | 230 | ?? resolvedTyping.inputSchema |
| 147 | ?? callInputSchemaFromInvocation({ | 231 | ?? undefined; |
| 148 | invocation: input.definition.invocation, | ||
| 149 | }); | ||
| 150 | const outputSchema = | 232 | const outputSchema = |
| 151 | resolvedTyping.outputSchema | 233 | resolvedTyping.outputSchema |
| 152 | ?? resolveSchemaWithRefHints( | 234 | ?? resolveSchemaWithRefHints( |
| @@ -160,6 +242,27 @@ const resolvePresentationSchemas = (input: { · 7 unmodified lines | |||
| 160 | }; | 242 | }; |
| 161 | }; | 243 | }; |
| 162 | 244 | ||
| 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 | |||
| 163 | const buildExampleInput = ( | 266 | const 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 | }; |
| @@ -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); |
| @@ -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"; |
| 25 | import { | ||
| 26 | serializeOpenApiParameterValue, | ||
| 27 | serializeOpenApiRequestBody, | ||
| 28 | withSerializedQueryEntries, | ||
| 29 | type SerializedOpenApiQueryEntry, | ||
| 30 | } from "./openapi-http-serialization"; | ||
| 25 | import { buildOpenApiToolPresentation } from "./openapi-tool-presentation"; | 31 | import { buildOpenApiToolPresentation } from "./openapi-tool-presentation"; |
| 26 | import { resolveSchemaWithRefHints } from "./openapi-schema-refs"; | 32 | import { resolveSchemaWithRefHints } from "./openapi-schema-refs"; |
| 27 | import { | 33 | import { |
| @@ -67,22 +73,6 @@ const parameterContainerKeys: Record< · 39 unmodified lines | |||
| 67 | cookie: ["cookies", "cookie"], | 73 | cookie: ["cookies", "cookie"], |
| 68 | }; | 74 | }; |
| 69 | 75 | ||
| 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 | |||
| 86 | const readParameterValue = ( | 76 | const 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, |
| @@ -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 | |||
| 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))), | ||
| 46 | }); | 68 | }); |
| 47 | 69 | ||
| 48 | export const OpenApiToolRequestBodySchema = Schema.Struct({ | 70 | export 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 | |||
| 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 | ), | ||
| 51 | }); | 85 | }); |
| 52 | 86 | ||
| 53 | export const OpenApiInvocationPayloadSchema = Schema.Struct({ | 87 | export 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 | ||
| 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 | |||
| 105 | export const OpenApiToolDocumentationSchema = Schema.Struct({ | 149 | export 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 | ||
| 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 | |||
| 113 | export const OpenApiToolProviderDataSchema = Schema.Struct({ | 232 | export 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 | ||
| 129 | export const OpenApiExtractedToolSchema = Schema.Struct({ | 253 | export 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 | ||
| 143 | export const OpenApiToolManifestSchema = Schema.Struct({ | 272 | export const OpenApiToolManifestSchema = Schema.Struct({ |
| @@ -155,7 +284,10 @@ export const OpenApiToolManifestSchema = Schema.Struct({ · 11 unmodified lines | |||
| 155 | export type OpenApiHttpMethod = typeof OpenApiHttpMethodSchema.Type; | 284 | export type OpenApiHttpMethod = typeof OpenApiHttpMethodSchema.Type; |
| 156 | export type OpenApiParameterLocation = typeof OpenApiParameterLocationSchema.Type; | 285 | export type OpenApiParameterLocation = typeof OpenApiParameterLocationSchema.Type; |
| 157 | export type OpenApiToolParameter = typeof OpenApiToolParameterSchema.Type; | 286 | export type OpenApiToolParameter = typeof OpenApiToolParameterSchema.Type; |
| 287 | export type OpenApiMediaContent = typeof OpenApiMediaContentSchema.Type; | ||
| 288 | export type OpenApiHeader = typeof OpenApiHeaderSchema.Type; | ||
| 158 | export type OpenApiToolRequestBody = typeof OpenApiToolRequestBodySchema.Type; | 289 | export type OpenApiToolRequestBody = typeof OpenApiToolRequestBodySchema.Type; |
| 290 | export type OpenApiServer = typeof OpenApiServerSchema.Type; | ||
| 159 | export type OpenApiInvocationPayload = typeof OpenApiInvocationPayloadSchema.Type; | 291 | export type OpenApiInvocationPayload = typeof OpenApiInvocationPayloadSchema.Type; |
| 160 | export type DiscoveryTypingPayload = typeof DiscoveryTypingPayloadSchema.Type; | 292 | export type DiscoveryTypingPayload = typeof DiscoveryTypingPayloadSchema.Type; |
| 161 | export type OpenApiRefHintValue = typeof OpenApiRefHintValueSchema.Type; | 293 | export type OpenApiRefHintValue = typeof OpenApiRefHintValueSchema.Type; |
| @@ -164,7 +296,11 @@ export type OpenApiExample = typeof OpenApiExampleSchema.Type; · 2 unmodified lines | |||
| 164 | export type OpenApiParameterDocumentation = typeof OpenApiParameterDocumentationSchema.Type; | 296 | export type OpenApiParameterDocumentation = typeof OpenApiParameterDocumentationSchema.Type; |
| 165 | export type OpenApiRequestBodyDocumentation = typeof OpenApiRequestBodyDocumentationSchema.Type; | 297 | export type OpenApiRequestBodyDocumentation = typeof OpenApiRequestBodyDocumentationSchema.Type; |
| 166 | export type OpenApiResponseDocumentation = typeof OpenApiResponseDocumentationSchema.Type; | 298 | export type OpenApiResponseDocumentation = typeof OpenApiResponseDocumentationSchema.Type; |
| 299 | export type OpenApiResponseVariant = typeof OpenApiResponseVariantSchema.Type; | ||
| 167 | export type OpenApiToolDocumentation = typeof OpenApiToolDocumentationSchema.Type; | 300 | 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; | ||
| 168 | export type OpenApiToolProviderData = typeof OpenApiToolProviderDataSchema.Type; | 304 | export type OpenApiToolProviderData = typeof OpenApiToolProviderDataSchema.Type; |
| 169 | export type OpenApiExtractedTool = typeof OpenApiExtractedToolSchema.Type; | 305 | export type OpenApiExtractedTool = typeof OpenApiExtractedToolSchema.Type; |
| 170 | export type OpenApiToolManifest = typeof OpenApiToolManifestSchema.Type; | 306 | export type OpenApiToolManifest = typeof OpenApiToolManifestSchema.Type; |