Move OpenAPI extraction to a single Rust-backed WebAssembly path so large specs process much faster while keeping one maintainable runtime flow across environments.
Unified · Split
| @@ -1,58 +1,58 @@ | |||
| 1 | .reference/ | 1 | # Environment variables |
| 2 | .vercel | 2 | .env |
| 3 | .env.* | ||
| 4 | !.env.example | ||
| 3 | .env*.local | 5 | .env*.local |
| 6 | |||
| 7 | # Vercel | ||
| 8 | .vercel | ||
| 9 | |||
| 10 | # Next.js | ||
| 4 | .next/ | 11 | .next/ |
| 12 | |||
| 13 | # Turbo | ||
| 5 | .turbo/ | 14 | .turbo/ |
| 15 | .turbo-prune-check/ | ||
| 16 | |||
| 17 | # Executor | ||
| 6 | .executor-v2/ | 18 | .executor-v2/ |
| 7 | 19 | ||
| 8 | # Local runtime artifacts | 20 | # Cloudflare Workers local state |
| 9 | **/.wrangler/ | 21 | **/.wrangler/ |
| 10 | **/.dev.vars | 22 | **/.dev.vars |
| 11 | 23 | ||
| 12 | # Local package manager caches and test outputs | 24 | # Dependencies |
| 13 | **/node_modules/ | 25 | **/node_modules/ |
| 14 | node_modules/.bun/ | 26 | |
| 15 | apps/convex/node_modules/.vite/ | 27 | # TypeScript build info |
| 16 | apps/convex/node_modules/@convex-dev/action-cache | ||
| 17 | *.tsbuildinfo | 28 | *.tsbuildinfo |
| 18 | tsconfig.tsbuildinfo | ||
| 19 | personal-notes/ | ||
| 20 | # dependencies (bun install) | ||
| 21 | node_modules | ||
| 22 | executor/bun.lock | ||
| 23 | 29 | ||
| 24 | # output | 30 | # Output / build artifacts |
| 25 | out | 31 | out |
| 26 | dist | 32 | dist |
| 27 | *.tgz | 33 | *.tgz |
| 28 | 34 | ||
| 29 | # code coverage | 35 | # Rust / Cargo build artifacts |
| 36 | **/target/ | ||
| 37 | tools/openapi-extractor-rs/pkg-*/ | ||
| 38 | |||
| 39 | # Code coverage | ||
| 30 | coverage | 40 | coverage |
| 31 | *.lcov | 41 | *.lcov |
| 32 | 42 | ||
| 33 | # logs | 43 | # Logs |
| 34 | logs | 44 | logs |
| 35 | *.log | 45 | *.log |
| 36 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json | 46 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json |
| 37 | 47 | ||
| 38 | # dotenv environment variable files | 48 | # Caches |
| 39 | .env | ||
| 40 | .env.production | ||
| 41 | .env.development.local | ||
| 42 | .env.test.local | ||
| 43 | .env.production.local | ||
| 44 | .env.local | ||
| 45 | |||
| 46 | # caches | ||
| 47 | .eslintcache | 49 | .eslintcache |
| 48 | .cache | 50 | .cache |
| 49 | *.tsbuildinfo | ||
| 50 | .reference/ | ||
| 51 | 51 | ||
| 52 | # IntelliJ based IDEs | 52 | # IntelliJ based IDEs |
| 53 | .idea | 53 | .idea |
| 54 | 54 | ||
| 55 | # Finder (MacOS) folder config | 55 | # Finder (macOS) |
| 56 | .DS_Store | 56 | .DS_Store |
| 57 | 57 | ||
| 58 | # Convex local backend state | 58 | # Convex local backend state |
| @@ -67,17 +67,13 @@ sources/data/*.sqlite-wal · 8 unmodified lines | |||
| 67 | # Composio toolkit scrape outputs | 67 | # Composio toolkit scrape outputs |
| 68 | sources/composio-scrape/data/ | 68 | sources/composio-scrape/data/ |
| 69 | 69 | ||
| 70 | # Cloudflare Workers local state | ||
| 71 | .wrangler/ | ||
| 72 | .dev.vars | ||
| 73 | |||
| 74 | # Generated push env file | ||
| 75 | executor/.env.executor-push | ||
| 76 | |||
| 77 | # Dev runner PID file | 70 | # Dev runner PID file |
| 78 | .dev.pids | 71 | .dev.pids |
| 72 | |||
| 73 | # Misc | ||
| 79 | *.har | 74 | *.har |
| 80 | .vercel | ||
| 81 | .env*.local | ||
| 82 | .chat-links.json | 75 | .chat-links.json |
| 83 | 76 | .reference/ | |
| 77 | personal-notes/ | ||
| 78 | .tmp/ | ||
| 79 | executor/.env.executor-push | ||
| @@ -17,7 +17,8 @@ · 16 unmodified lines | |||
| 17 | "./workspaces/api": "./src/workspaces/api.ts" | 17 | "./workspaces/api": "./src/workspaces/api.ts" |
| 18 | }, | 18 | }, |
| 19 | "scripts": { | 19 | "scripts": { |
| 20 | "typecheck": "bunx tsc --noEmit -p tsconfig.json" | 20 | "typecheck": "bunx tsc --noEmit -p tsconfig.json", |
| 21 | "extractor:build": "../../tools/openapi-extractor-rs/build-wasm.sh" | ||
| 21 | }, | 22 | }, |
| 22 | "dependencies": { | 23 | "dependencies": { |
| 23 | "@apidevtools/swagger-parser": "^12.1.0", | 24 | "@apidevtools/swagger-parser": "^12.1.0", |
| @@ -33,4 +34,4 @@ · 9 unmodified lines | |||
| 33 | "@effect/vitest": "catalog:", | 34 | "@effect/vitest": "catalog:", |
| 34 | "vitest": "catalog:" | 35 | "vitest": "catalog:" |
| 35 | } | 36 | } |
| 36 | } | 37 | } |
| @@ -25,12 +25,11 @@ export const parseOpenApiDocument = (input: string): unknown => { · 24 unmodified lines | |||
| 25 | } | 25 | } |
| 26 | }; | 26 | }; |
| 27 | 27 | ||
| 28 | export const fetchOpenApiDocument = async (url: string): Promise<unknown> => { | 28 | export const fetchOpenApiDocument = async (url: string): Promise<string> => { |
| 29 | const response = await fetch(url); | 29 | const response = await fetch(url); |
| 30 | if (!response.ok) { | 30 | if (!response.ok) { |
| 31 | throw new Error(`Failed fetching OpenAPI spec (${response.status})`); | 31 | throw new Error(`Failed fetching OpenAPI spec (${response.status})`); |
| 32 | } | 32 | } |
| 33 | 33 | ||
| 34 | const bodyText = await response.text(); | 34 | return response.text(); |
| 35 | return parseOpenApiDocument(bodyText); | ||
| 36 | }; | 35 | }; |
| @@ -3,23 +3,11 @@ import { · 2 unmodified lines | |||
| 3 | type ToolArtifactStore, | 3 | type ToolArtifactStore, |
| 4 | } from "@executor-v2/persistence-ports"; | 4 | } from "@executor-v2/persistence-ports"; |
| 5 | import { | 5 | import { |
| 6 | OPEN_API_HTTP_METHODS, | ||
| 7 | OPEN_API_PARAMETER_LOCATIONS, | ||
| 8 | OpenApiExtractedToolSchema, | ||
| 9 | OpenApiInvocationPayloadSchema, | ||
| 10 | OpenApiToolManifestSchema, | 6 | OpenApiToolManifestSchema, |
| 11 | OpenApiToolParameterSchema, | ||
| 12 | OpenApiToolRequestBodySchema, | ||
| 13 | ToolArtifactIdSchema, | 7 | ToolArtifactIdSchema, |
| 14 | type DiscoveryTypingPayload, | ||
| 15 | type OpenApiExtractedTool, | ||
| 16 | type OpenApiHttpMethod, | ||
| 17 | type OpenApiInvocationPayload, | ||
| 18 | type OpenApiToolManifest, | ||
| 19 | type OpenApiToolParameter, | ||
| 20 | type OpenApiToolRequestBody, | ||
| 21 | type Source, | 8 | type Source, |
| 22 | type ToolArtifact, | 9 | type ToolArtifact, |
| 10 | type OpenApiToolManifest, | ||
| 23 | } from "@executor-v2/schema"; | 11 | } from "@executor-v2/schema"; |
| 24 | import * as Data from "effect/Data"; | 12 | import * as Data from "effect/Data"; |
| 25 | import * as Effect from "effect/Effect"; | 13 | import * as Effect from "effect/Effect"; |
| @@ -28,14 +16,9 @@ import * as Option from "effect/Option"; · 2 unmodified lines | |||
| 28 | import * as ParseResult from "effect/ParseResult"; | 16 | import * as ParseResult from "effect/ParseResult"; |
| 29 | import * as Schema from "effect/Schema"; | 17 | import * as Schema from "effect/Schema"; |
| 30 | 18 | ||
| 31 | const HTTP_METHODS = OPEN_API_HTTP_METHODS; | 19 | import { extractOpenApiManifestJsonWithWasm } from "./openapi-extractor-wasm"; |
| 32 | 20 | ||
| 33 | type HttpMethod = OpenApiHttpMethod; | 21 | type OpenApiExtractionStage = "validate" | "extract" | "encode_manifest"; |
| 34 | |||
| 35 | type OpenApiExtractionStage = | ||
| 36 | | "validate" | ||
| 37 | | "extract" | ||
| 38 | | "encode_manifest"; | ||
| 39 | 22 | ||
| 40 | export class OpenApiExtractionError extends Data.TaggedError("OpenApiExtractionError")<{ | 23 | export class OpenApiExtractionError extends Data.TaggedError("OpenApiExtractionError")<{ |
| 41 | sourceName: string; | 24 | sourceName: string; |
| @@ -44,21 +27,9 @@ export class OpenApiExtractionError extends Data.TaggedError("OpenApiExtractionE · 2 unmodified lines | |||
| 44 | details: string | null; | 27 | details: string | null; |
| 45 | }> {} | 28 | }> {} |
| 46 | 29 | ||
| 47 | const ExtractedToolParameterSchema = OpenApiToolParameterSchema; | 30 | const manifestFromJsonSchema = Schema.parseJson(OpenApiToolManifestSchema); |
| 48 | const ExtractedToolRequestBodySchema = OpenApiToolRequestBodySchema; | 31 | const decodeManifestFromJson = Schema.decodeUnknown(manifestFromJsonSchema); |
| 49 | const ExtractedToolInvocationSchema = OpenApiInvocationPayloadSchema; | 32 | const encodeManifestToJson = Schema.encode(manifestFromJsonSchema); |
| 50 | const ExtractedToolSchema = OpenApiExtractedToolSchema; | ||
| 51 | const ToolManifestSchema = OpenApiToolManifestSchema; | ||
| 52 | |||
| 53 | type ExtractedToolParameter = OpenApiToolParameter; | ||
| 54 | type ExtractedToolRequestBody = OpenApiToolRequestBody; | ||
| 55 | type ExtractedToolInvocation = OpenApiInvocationPayload; | ||
| 56 | type ExtractedTool = OpenApiExtractedTool; | ||
| 57 | type ToolManifest = OpenApiToolManifest; | ||
| 58 | type DiscoveryTyping = DiscoveryTypingPayload; | ||
| 59 | |||
| 60 | const ToolManifestFromJsonSchema = Schema.parseJson(ToolManifestSchema); | ||
| 61 | const encodeManifestToJson = Schema.encode(ToolManifestFromJsonSchema); | ||
| 62 | const decodeToolArtifactId = Schema.decodeUnknownSync(ToolArtifactIdSchema); | 33 | const decodeToolArtifactId = Schema.decodeUnknownSync(ToolArtifactIdSchema); |
| 63 | 34 | ||
| 64 | export type ToolManifestDiff = { | 35 | export type ToolManifestDiff = { |
| @@ -70,7 +41,7 @@ export type ToolManifestDiff = { · 5 unmodified lines | |||
| 70 | 41 | ||
| 71 | export type RefreshOpenApiArtifactResult = { | 42 | export type RefreshOpenApiArtifactResult = { |
| 72 | artifact: ToolArtifact; | 43 | artifact: ToolArtifact; |
| 73 | manifest: ToolManifest; | 44 | manifest: OpenApiToolManifest; |
| 74 | diff: ToolManifestDiff; | 45 | diff: ToolManifestDiff; |
| 75 | reused: boolean; | 46 | reused: boolean; |
| 76 | }; | 47 | }; |
| @@ -82,484 +53,6 @@ type RefreshOpenApiArtifactInput = { · 5 unmodified lines | |||
| 82 | now?: () => number; | 53 | now?: () => number; |
| 83 | }; | 54 | }; |
| 84 | 55 | ||
| 85 | const UnknownRecordSchema = Schema.Record({ | ||
| 86 | key: Schema.String, | ||
| 87 | value: Schema.Unknown, | ||
| 88 | }); | ||
| 89 | |||
| 90 | type UnknownRecord = typeof UnknownRecordSchema.Type; | ||
| 91 | |||
| 92 | const isUnknownRecord = Schema.is(UnknownRecordSchema); | ||
| 93 | |||
| 94 | const OpenApiParameterInputSchema = Schema.Struct({ | ||
| 95 | name: Schema.String, | ||
| 96 | in: Schema.Literal(...OPEN_API_PARAMETER_LOCATIONS), | ||
| 97 | required: Schema.optional(Schema.Boolean), | ||
| 98 | }); | ||
| 99 | |||
| 100 | type OpenApiParameterInput = typeof OpenApiParameterInputSchema.Type; | ||
| 101 | |||
| 102 | const isOpenApiParameterInput = Schema.is(OpenApiParameterInputSchema); | ||
| 103 | |||
| 104 | const OpenApiRequestBodyInputSchema = Schema.Struct({ | ||
| 105 | required: Schema.optional(Schema.Boolean), | ||
| 106 | content: Schema.optional(UnknownRecordSchema), | ||
| 107 | }); | ||
| 108 | |||
| 109 | type OpenApiRequestBodyInput = typeof OpenApiRequestBodyInputSchema.Type; | ||
| 110 | |||
| 111 | const isOpenApiRequestBodyInput = Schema.is(OpenApiRequestBodyInputSchema); | ||
| 112 | |||
| 113 | const toExtractedToolParameter = ( | ||
| 114 | value: unknown, | ||
| 115 | ): ExtractedToolParameter | null => { | ||
| 116 | if (!isOpenApiParameterInput(value)) { | ||
| 117 | return null; | ||
| 118 | } | ||
| 119 | |||
| 120 | const parameter: OpenApiParameterInput = value; | ||
| 121 | const name = parameter.name.trim(); | ||
| 122 | |||
| 123 | if (name.length === 0) { | ||
| 124 | return null; | ||
| 125 | } | ||
| 126 | |||
| 127 | return { | ||
| 128 | name, | ||
| 129 | location: parameter.in, | ||
| 130 | required: parameter.in === "path" || parameter.required === true, | ||
| 131 | }; | ||
| 132 | }; | ||
| 133 | |||
| 134 | const mergeParameters = ( | ||
| 135 | pathItem: UnknownRecord, | ||
| 136 | operation: UnknownRecord, | ||
| 137 | ): Array<ExtractedToolParameter> => { | ||
| 138 | const byKey = new Map<string, ExtractedToolParameter>(); | ||
| 139 | |||
| 140 | const addParameters = (candidate: unknown) => { | ||
| 141 | if (!Array.isArray(candidate)) { | ||
| 142 | return; | ||
| 143 | } | ||
| 144 | |||
| 145 | for (const item of candidate) { | ||
| 146 | const parameter = toExtractedToolParameter(item); | ||
| 147 | if (!parameter) { | ||
| 148 | continue; | ||
| 149 | } | ||
| 150 | byKey.set(`${parameter.location}:${parameter.name}`, parameter); | ||
| 151 | } | ||
| 152 | }; | ||
| 153 | |||
| 154 | addParameters(pathItem.parameters); | ||
| 155 | addParameters(operation.parameters); | ||
| 156 | |||
| 157 | return Array.from(byKey.values()).sort((left, right) => { | ||
| 158 | if (left.location === right.location) { | ||
| 159 | return left.name.localeCompare(right.name); | ||
| 160 | } | ||
| 161 | |||
| 162 | return left.location.localeCompare(right.location); | ||
| 163 | }); | ||
| 164 | }; | ||
| 165 | |||
| 166 | const extractRequestBody = ( | ||
| 167 | operation: UnknownRecord, | ||
| 168 | ): ExtractedToolRequestBody | null => { | ||
| 169 | const requestBody = operation.requestBody; | ||
| 170 | |||
| 171 | if (!isOpenApiRequestBodyInput(requestBody)) { | ||
| 172 | return null; | ||
| 173 | } | ||
| 174 | |||
| 175 | const openApiRequestBody: OpenApiRequestBodyInput = requestBody; | ||
| 176 | const contentTypes = openApiRequestBody.content | ||
| 177 | ? Object.keys(openApiRequestBody.content).sort() | ||
| 178 | : []; | ||
| 179 | |||
| 180 | return { | ||
| 181 | required: openApiRequestBody.required === true, | ||
| 182 | contentTypes, | ||
| 183 | }; | ||
| 184 | }; | ||
| 185 | |||
| 186 | const buildInvocationMetadata = ( | ||
| 187 | method: HttpMethod, | ||
| 188 | pathValue: string, | ||
| 189 | pathItem: UnknownRecord, | ||
| 190 | operation: UnknownRecord, | ||
| 191 | ): ExtractedToolInvocation => ({ | ||
| 192 | method, | ||
| 193 | pathTemplate: pathValue, | ||
| 194 | parameters: mergeParameters(pathItem, operation), | ||
| 195 | requestBody: extractRequestBody(operation), | ||
| 196 | }); | ||
| 197 | |||
| 198 | const collectRefKeys = (value: unknown, refs: Set<string>): void => { | ||
| 199 | if (Array.isArray(value)) { | ||
| 200 | for (const item of value) { | ||
| 201 | collectRefKeys(item, refs); | ||
| 202 | } | ||
| 203 | return; | ||
| 204 | } | ||
| 205 | |||
| 206 | if (!isUnknownRecord(value)) { | ||
| 207 | return; | ||
| 208 | } | ||
| 209 | |||
| 210 | const reference = value.$ref; | ||
| 211 | if (typeof reference === "string" && reference.startsWith("#/")) { | ||
| 212 | refs.add(reference); | ||
| 213 | } | ||
| 214 | |||
| 215 | for (const nestedValue of Object.values(value)) { | ||
| 216 | collectRefKeys(nestedValue, refs); | ||
| 217 | } | ||
| 218 | }; | ||
| 219 | |||
| 220 | const resolveJsonPointer = ( | ||
| 221 | root: UnknownRecord, | ||
| 222 | pointer: string, | ||
| 223 | ): unknown | null => { | ||
| 224 | if (!pointer.startsWith("#/")) { | ||
| 225 | return null; | ||
| 226 | } | ||
| 227 | |||
| 228 | const parts = pointer | ||
| 229 | .slice(2) | ||
| 230 | .split("/") | ||
| 231 | .map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~")); | ||
| 232 | |||
| 233 | let current: unknown = root; | ||
| 234 | |||
| 235 | for (const part of parts) { | ||
| 236 | if (!isUnknownRecord(current)) { | ||
| 237 | return null; | ||
| 238 | } | ||
| 239 | |||
| 240 | current = current[part]; | ||
| 241 | if (current === undefined) { | ||
| 242 | return null; | ||
| 243 | } | ||
| 244 | } | ||
| 245 | |||
| 246 | return current; | ||
| 247 | }; | ||
| 248 | |||
| 249 | const pickSchemaFromContent = (content: unknown): unknown | null => { | ||
| 250 | if (!isUnknownRecord(content)) { | ||
| 251 | return null; | ||
| 252 | } | ||
| 253 | |||
| 254 | const preferred = ["application/json", ...Object.keys(content).sort()]; | ||
| 255 | const seen = new Set<string>(); | ||
| 256 | |||
| 257 | for (const mediaType of preferred) { | ||
| 258 | if (seen.has(mediaType)) { | ||
| 259 | continue; | ||
| 260 | } | ||
| 261 | seen.add(mediaType); | ||
| 262 | |||
| 263 | const mediaTypeValue = content[mediaType]; | ||
| 264 | if (!isUnknownRecord(mediaTypeValue)) { | ||
| 265 | continue; | ||
| 266 | } | ||
| 267 | |||
| 268 | if (mediaTypeValue.schema !== undefined) { | ||
| 269 | return mediaTypeValue.schema; | ||
| 270 | } | ||
| 271 | } | ||
| 272 | |||
| 273 | return null; | ||
| 274 | }; | ||
| 275 | |||
| 276 | const extractRequestBodySchema = (operation: UnknownRecord): unknown | null => { | ||
| 277 | const requestBody = operation.requestBody; | ||
| 278 | if (!isOpenApiRequestBodyInput(requestBody)) { | ||
| 279 | return null; | ||
| 280 | } | ||
| 281 | |||
| 282 | return pickSchemaFromContent(requestBody.content); | ||
| 283 | }; | ||
| 284 | |||
| 285 | const responseStatusRank = (statusCode: string): number => { | ||
| 286 | if (/^2\d\d$/.test(statusCode)) { | ||
| 287 | return 0; | ||
| 288 | } | ||
| 289 | |||
| 290 | if (statusCode === "default") { | ||
| 291 | return 1; | ||
| 292 | } | ||
| 293 | |||
| 294 | return 2; | ||
| 295 | }; | ||
| 296 | |||
| 297 | const extractResponseSchema = (operation: UnknownRecord): unknown | null => { | ||
| 298 | if (!isUnknownRecord(operation.responses)) { | ||
| 299 | return null; | ||
| 300 | } | ||
| 301 | |||
| 302 | const responseCodes = Object.keys(operation.responses).sort( | ||
| 303 | (left, right) => responseStatusRank(left) - responseStatusRank(right), | ||
| 304 | ); | ||
| 305 | |||
| 306 | for (const responseCode of responseCodes) { | ||
| 307 | const response = operation.responses[responseCode]; | ||
| 308 | if (!isUnknownRecord(response)) { | ||
| 309 | continue; | ||
| 310 | } | ||
| 311 | |||
| 312 | const schema = pickSchemaFromContent(response.content); | ||
| 313 | if (schema !== null) { | ||
| 314 | return schema; | ||
| 315 | } | ||
| 316 | } | ||
| 317 | |||
| 318 | return null; | ||
| 319 | }; | ||
| 320 | |||
| 321 | const collectParameterSchemaByKey = ( | ||
| 322 | pathItem: UnknownRecord, | ||
| 323 | operation: UnknownRecord, | ||
| 324 | ): Map<string, unknown> => { | ||
| 325 | const schemasByKey = new Map<string, unknown>(); | ||
| 326 | |||
| 327 | const addParameters = (candidate: unknown) => { | ||
| 328 | if (!Array.isArray(candidate)) { | ||
| 329 | return; | ||
| 330 | } | ||
| 331 | |||
| 332 | for (const item of candidate) { | ||
| 333 | if (!isUnknownRecord(item)) { | ||
| 334 | continue; | ||
| 335 | } | ||
| 336 | |||
| 337 | const parameter = toExtractedToolParameter(item); | ||
| 338 | if (!parameter || item.schema === undefined) { | ||
| 339 | continue; | ||
| 340 | } | ||
| 341 | |||
| 342 | schemasByKey.set(`${parameter.location}:${parameter.name}`, item.schema); | ||
| 343 | } | ||
| 344 | }; | ||
| 345 | |||
| 346 | addParameters(pathItem.parameters); | ||
| 347 | addParameters(operation.parameters); | ||
| 348 | |||
| 349 | return schemasByKey; | ||
| 350 | }; | ||
| 351 | |||
| 352 | const buildInputSchema = ( | ||
| 353 | pathItem: UnknownRecord, | ||
| 354 | operation: UnknownRecord, | ||
| 355 | invocation: ExtractedToolInvocation, | ||
| 356 | ): unknown | null => { | ||
| 357 | const parameterSchemaByKey = collectParameterSchemaByKey(pathItem, operation); | ||
| 358 | |||
| 359 | const properties: Record<string, unknown> = {}; | ||
| 360 | const required = new Set<string>(); | ||
| 361 | |||
| 362 | for (const parameter of invocation.parameters) { | ||
| 363 | const key = `${parameter.location}:${parameter.name}`; | ||
| 364 | properties[parameter.name] = parameterSchemaByKey.get(key) ?? { type: "string" }; | ||
| 365 | if (parameter.required) { | ||
| 366 | required.add(parameter.name); | ||
| 367 | } | ||
| 368 | } | ||
| 369 | |||
| 370 | const requestBodySchema = extractRequestBodySchema(operation); | ||
| 371 | if (requestBodySchema !== null) { | ||
| 372 | properties.body = requestBodySchema; | ||
| 373 | if (invocation.requestBody?.required) { | ||
| 374 | required.add("body"); | ||
| 375 | } | ||
| 376 | } | ||
| 377 | |||
| 378 | if (Object.keys(properties).length === 0) { | ||
| 379 | return null; | ||
| 380 | } | ||
| 381 | |||
| 382 | return { | ||
| 383 | type: "object", | ||
| 384 | properties, | ||
| 385 | required: [...required].sort(), | ||
| 386 | additionalProperties: false, | ||
| 387 | }; | ||
| 388 | }; | ||
| 389 | |||
| 390 | const encodeStableJson = (value: unknown): string => | ||
| 391 | JSON.stringify(toStableValue(value)); | ||
| 392 | |||
| 393 | const buildToolTyping = ( | ||
| 394 | pathItem: UnknownRecord, | ||
| 395 | operation: UnknownRecord, | ||
| 396 | invocation: ExtractedToolInvocation, | ||
| 397 | ): DiscoveryTyping | undefined => { | ||
| 398 | const inputSchema = buildInputSchema(pathItem, operation, invocation); | ||
| 399 | const outputSchema = extractResponseSchema(operation); | ||
| 400 | |||
| 401 | if (inputSchema === null && outputSchema === null) { | ||
| 402 | return undefined; | ||
| 403 | } | ||
| 404 | |||
| 405 | const refs = new Set<string>(); | ||
| 406 | if (inputSchema !== null) { | ||
| 407 | collectRefKeys(inputSchema, refs); | ||
| 408 | } | ||
| 409 | |||
| 410 | if (outputSchema !== null) { | ||
| 411 | collectRefKeys(outputSchema, refs); | ||
| 412 | } | ||
| 413 | |||
| 414 | const refHintKeys = [...refs].sort(); | ||
| 415 | |||
| 416 | return { | ||
| 417 | inputSchemaJson: inputSchema ? encodeStableJson(inputSchema) : undefined, | ||
| 418 | outputSchemaJson: outputSchema ? encodeStableJson(outputSchema) : undefined, | ||
| 419 | refHintKeys: refHintKeys.length > 0 ? refHintKeys : undefined, | ||
| 420 | }; | ||
| 421 | }; | ||
| 422 | |||
| 423 | const buildRefHintTable = ( | ||
| 424 | openApiSpec: UnknownRecord, | ||
| 425 | initialRefKeys: ReadonlyArray<string>, | ||
| 426 | ): Record<string, string> => { | ||
| 427 | const queue = [...new Set(initialRefKeys)]; | ||
| 428 | const seen = new Set<string>(); | ||
| 429 | const table: Record<string, string> = {}; | ||
| 430 | |||
| 431 | while (queue.length > 0) { | ||
| 432 | const refKey = queue.shift(); | ||
| 433 | if (!refKey || seen.has(refKey)) { | ||
| 434 | continue; | ||
| 435 | } | ||
| 436 | |||
| 437 | seen.add(refKey); | ||
| 438 | |||
| 439 | const resolved = resolveJsonPointer(openApiSpec, refKey); | ||
| 440 | if (resolved === null) { | ||
| 441 | continue; | ||
| 442 | } | ||
| 443 | |||
| 444 | table[refKey] = encodeStableJson(resolved); | ||
| 445 | |||
| 446 | const nested = new Set<string>(); | ||
| 447 | collectRefKeys(resolved, nested); | ||
| 448 | for (const nestedRef of nested) { | ||
| 449 | if (!seen.has(nestedRef)) { | ||
| 450 | queue.push(nestedRef); | ||
| 451 | } | ||
| 452 | } | ||
| 453 | } | ||
| 454 | |||
| 455 | return table; | ||
| 456 | }; | ||
| 457 | |||
| 458 | const toStableValue = (value: unknown): unknown => { | ||
| 459 | if (Array.isArray(value)) { | ||
| 460 | return value.map(toStableValue); | ||
| 461 | } | ||
| 462 | |||
| 463 | if (isUnknownRecord(value)) { | ||
| 464 | const stableRecord: Record<string, unknown> = {}; | ||
| 465 | for (const key of Object.keys(value).sort()) { | ||
| 466 | stableRecord[key] = toStableValue(value[key]); | ||
| 467 | } | ||
| 468 | return stableRecord; | ||
| 469 | } | ||
| 470 | |||
| 471 | return value; | ||
| 472 | }; | ||
| 473 | |||
| 474 | const hashString = (value: string): string => { | ||
| 475 | let hash = 0xcbf29ce484222325n; | ||
| 476 | const prime = 0x100000001b3n; | ||
| 477 | |||
| 478 | for (let index = 0; index < value.length; index += 1) { | ||
| 479 | hash ^= BigInt(value.charCodeAt(index)); | ||
| 480 | hash = BigInt.asUintN(64, hash * prime); | ||
| 481 | } | ||
| 482 | |||
| 483 | return hash.toString(16).padStart(16, "0"); | ||
| 484 | }; | ||
| 485 | |||
| 486 | const hashUnknown = (value: unknown): string => | ||
| 487 | hashString(JSON.stringify(toStableValue(value))); | ||
| 488 | |||
| 489 | const normalizePathForToolId = (pathValue: string): string => | ||
| 490 | pathValue | ||
| 491 | .trim() | ||
| 492 | .replace(/^\//, "") | ||
| 493 | .replace(/[^a-zA-Z0-9]+/g, "_") | ||
| 494 | .replace(/^_+|_+$/g, "") | ||
| 495 | .toLowerCase() || "root"; | ||
| 496 | |||
| 497 | const buildToolId = ( | ||
| 498 | method: HttpMethod, | ||
| 499 | pathValue: string, | ||
| 500 | operation: Record<string, unknown>, | ||
| 501 | ): string => { | ||
| 502 | const operationId = operation.operationId; | ||
| 503 | if (typeof operationId === "string" && operationId.trim().length > 0) { | ||
| 504 | return operationId.trim(); | ||
| 505 | } | ||
| 506 | |||
| 507 | return `${method}_${normalizePathForToolId(pathValue)}`; | ||
| 508 | }; | ||
| 509 | |||
| 510 | const buildToolName = ( | ||
| 511 | method: HttpMethod, | ||
| 512 | pathValue: string, | ||
| 513 | operation: Record<string, unknown>, | ||
| 514 | ): string => { | ||
| 515 | const summary = operation.summary; | ||
| 516 | if (typeof summary === "string" && summary.trim().length > 0) { | ||
| 517 | return summary.trim(); | ||
| 518 | } | ||
| 519 | |||
| 520 | const operationId = operation.operationId; | ||
| 521 | if (typeof operationId === "string" && operationId.trim().length > 0) { | ||
| 522 | return operationId.trim(); | ||
| 523 | } | ||
| 524 | |||
| 525 | return `${method.toUpperCase()} ${pathValue}`; | ||
| 526 | }; | ||
| 527 | |||
| 528 | const buildToolDescription = (operation: Record<string, unknown>): string | null => { | ||
| 529 | const description = operation.description; | ||
| 530 | if (typeof description === "string" && description.trim().length > 0) { | ||
| 531 | return description.trim(); | ||
| 532 | } | ||
| 533 | |||
| 534 | const summary = operation.summary; | ||
| 535 | if (typeof summary === "string" && summary.trim().length > 0) { | ||
| 536 | return summary.trim(); | ||
| 537 | } | ||
| 538 | |||
| 539 | return null; | ||
| 540 | }; | ||
| 541 | |||
| 542 | const ensureUniqueToolIds = ( | ||
| 543 | sourceName: string, | ||
| 544 | tools: ReadonlyArray<ExtractedTool>, | ||
| 545 | ): Effect.Effect<void, OpenApiExtractionError> => | ||
| 546 | Effect.gen(function* () { | ||
| 547 | const seenToolIds = new Set<string>(); | ||
| 548 | |||
| 549 | for (const tool of tools) { | ||
| 550 | if (seenToolIds.has(tool.toolId)) { | ||
| 551 | return yield* new OpenApiExtractionError({ | ||
| 552 | sourceName, | ||
| 553 | stage: "extract", | ||
| 554 | message: `Duplicate toolId detected: ${tool.toolId}`, | ||
| 555 | details: `${tool.method.toUpperCase()} ${tool.path}`, | ||
| 556 | }); | ||
| 557 | } | ||
| 558 | |||
| 559 | seenToolIds.add(tool.toolId); | ||
| 560 | } | ||
| 561 | }); | ||
| 562 | |||
| 563 | const toExtractionError = ( | 56 | const toExtractionError = ( |
| 564 | sourceName: string, | 57 | sourceName: string, |
| 565 | stage: OpenApiExtractionStage, | 58 | stage: OpenApiExtractionStage, |
| @@ -576,97 +69,54 @@ const toExtractionError = ( · 10 unmodified lines | |||
| 576 | : String(cause), | 69 | : String(cause), |
| 577 | }); | 70 | }); |
| 578 | 71 | ||
| 579 | export const extractOpenApiManifest = ( | 72 | const normalizeOpenApiDocumentText = ( |
| 580 | sourceName: string, | 73 | sourceName: string, |
| 581 | openApiSpec: unknown, | 74 | openApiSpec: unknown, |
| 582 | ): Effect.Effect<ToolManifest, OpenApiExtractionError> => | 75 | ): Effect.Effect<string, OpenApiExtractionError> => { |
| 583 | Effect.gen(function* () { | 76 | if (typeof openApiSpec === "string") { |
| 584 | if (!isUnknownRecord(openApiSpec)) { | 77 | return Effect.succeed(openApiSpec); |
| 585 | return yield* new OpenApiExtractionError({ | 78 | } |
| 79 | |||
| 80 | return Effect.try({ | ||
| 81 | try: () => JSON.stringify(openApiSpec), | ||
| 82 | catch: (cause) => | ||
| 83 | new OpenApiExtractionError({ | ||
| 586 | sourceName, | 84 | sourceName, |
| 587 | stage: "validate", | 85 | stage: "validate", |
| 588 | message: "OpenAPI spec must be an object", | 86 | message: "Unable to serialize OpenAPI input", |
| 589 | details: null, | 87 | details: String(cause), |
| 590 | }); | 88 | }), |
| 591 | } | 89 | }); |
| 592 | 90 | }; | |
| 593 | const specRecord: UnknownRecord = openApiSpec; | ||
| 594 | const pathsValue = specRecord.paths; | ||
| 595 | if (!isUnknownRecord(pathsValue)) { | ||
| 596 | return { | ||
| 597 | version: 1 as const, | ||
| 598 | sourceHash: hashUnknown(specRecord), | ||
| 599 | tools: [], | ||
| 600 | }; | ||
| 601 | } | ||
| 602 | |||
| 603 | const tools: Array<ExtractedTool> = []; | ||
| 604 | |||
| 605 | for (const pathValue of Object.keys(pathsValue).sort()) { | ||
| 606 | const pathItem = pathsValue[pathValue]; | ||
| 607 | if (!isUnknownRecord(pathItem)) { | ||
| 608 | continue; | ||
| 609 | } | ||
| 610 | |||
| 611 | for (const method of HTTP_METHODS) { | ||
| 612 | const operation = pathItem[method]; | ||
| 613 | if (!isUnknownRecord(operation)) { | ||
| 614 | continue; | ||
| 615 | } | ||
| 616 | |||
| 617 | const invocation = buildInvocationMetadata( | ||
| 618 | method, | ||
| 619 | pathValue, | ||
| 620 | pathItem, | ||
| 621 | operation, | ||
| 622 | ); | ||
| 623 | const typing = buildToolTyping(pathItem, operation, invocation); | ||
| 624 | 91 | ||
| 625 | tools.push({ | 92 | export const extractOpenApiManifest = ( |
| 626 | toolId: buildToolId(method, pathValue, operation), | 93 | sourceName: string, |
| 627 | name: buildToolName(method, pathValue, operation), | 94 | openApiSpec: unknown, |
| 628 | description: buildToolDescription(operation), | 95 | ): Effect.Effect<OpenApiToolManifest, OpenApiExtractionError> => |
| 629 | method, | 96 | Effect.gen(function* () { |
| 630 | path: pathValue, | 97 | const openApiDocumentText = yield* normalizeOpenApiDocumentText(sourceName, openApiSpec); |
| 631 | invocation, | ||
| 632 | operationHash: hashUnknown({ | ||
| 633 | method, | ||
| 634 | path: pathValue, | ||
| 635 | operation, | ||
| 636 | invocation, | ||
| 637 | }), | ||
| 638 | typing, | ||
| 639 | }); | ||
| 640 | } | ||
| 641 | } | ||
| 642 | 98 | ||
| 643 | tools.sort((left, right) => left.toolId.localeCompare(right.toolId)); | 99 | const manifestJson = yield* Effect.tryPromise({ |
| 644 | yield* ensureUniqueToolIds(sourceName, tools); | 100 | try: () => extractOpenApiManifestJsonWithWasm(sourceName, openApiDocumentText), |
| 101 | catch: (cause) => toExtractionError(sourceName, "extract", cause), | ||
| 102 | }); | ||
| 645 | 103 | ||
| 646 | const directRefKeys = tools.flatMap( | 104 | return yield* decodeManifestFromJson(manifestJson).pipe( |
| 647 | (tool) => tool.typing?.refHintKeys ?? [], | 105 | Effect.mapError((cause) => toExtractionError(sourceName, "extract", cause)), |
| 648 | ); | 106 | ); |
| 649 | const refHintTable = buildRefHintTable(specRecord, directRefKeys); | 107 | }); |
| 650 | |||
| 651 | return { | ||
| 652 | version: 1 as const, | ||
| 653 | sourceHash: hashUnknown(openApiSpec), | ||
| 654 | tools, | ||
| 655 | refHintTable: Object.keys(refHintTable).length > 0 ? refHintTable : undefined, | ||
| 656 | }; | ||
| 657 | }).pipe(Effect.mapError((cause) => toExtractionError(sourceName, "extract", cause))); | ||
| 658 | 108 | ||
| 659 | const makeToolArtifactId = (source: Source): ToolArtifact["id"] => | 109 | const makeToolArtifactId = (source: Source): ToolArtifact["id"] => |
| 660 | decodeToolArtifactId(`tool_artifact_${source.id}`); | 110 | decodeToolArtifactId(`tool_artifact_${source.id}`); |
| 661 | 111 | ||
| 662 | const diffForReusedManifest = (manifest: ToolManifest): ToolManifestDiff => ({ | 112 | const diffForReusedManifest = (manifest: OpenApiToolManifest): ToolManifestDiff => ({ |
| 663 | added: [], | 113 | added: [], |
| 664 | changed: [], | 114 | changed: [], |
| 665 | removed: [], | 115 | removed: [], |
| 666 | unchangedCount: manifest.tools.length, | 116 | unchangedCount: manifest.tools.length, |
| 667 | }); | 117 | }); |
| 668 | 118 | ||
| 669 | const diffForReplacedManifest = (manifest: ToolManifest): ToolManifestDiff => ({ | 119 | const diffForReplacedManifest = (manifest: OpenApiToolManifest): ToolManifestDiff => ({ |
| 670 | added: manifest.tools.map((tool) => tool.toolId), | 120 | added: manifest.tools.map((tool) => tool.toolId), |
| 671 | changed: [], | 121 | changed: [], |
| 672 | removed: [], | 122 | removed: [], |
| @@ -0,0 +1,27 @@ | |||
| 1 | import { readFile } from "node:fs/promises"; | ||
| 2 | |||
| 3 | import initWasmExtractor, { | ||
| 4 | extract_manifest_json_wasm, | ||
| 5 | } from "./openapi-extractor-wasm/openapi_extractor.js"; | ||
| 6 | |||
| 7 | let initPromise: Promise<void> | undefined; | ||
| 8 | |||
| 9 | const ensureWasmReady = (): Promise<void> => { | ||
| 10 | if (!initPromise) { | ||
| 11 | initPromise = readFile( | ||
| 12 | new URL("./openapi-extractor-wasm/openapi_extractor_bg.wasm", import.meta.url), | ||
| 13 | ).then((wasmBytes) => | ||
| 14 | initWasmExtractor({ module_or_path: wasmBytes }).then(() => undefined) | ||
| 15 | ); | ||
| 16 | } | ||
| 17 | |||
| 18 | return initPromise; | ||
| 19 | }; | ||
| 20 | |||
| 21 | export const extractOpenApiManifestJsonWithWasm = ( | ||
| 22 | sourceName: string, | ||
| 23 | openApiDocumentText: string, | ||
| 24 | ): Promise<string> => | ||
| 25 | ensureWasmReady().then(() => | ||
| 26 | extract_manifest_json_wasm(sourceName, openApiDocumentText) | ||
| 27 | ); | ||
| @@ -0,0 +1,39 @@ | |||
| 1 | /* tslint:disable */ | ||
| 2 | /* eslint-disable */ | ||
| 3 | |||
| 4 | export function extract_manifest_json_wasm(source_name: string, openapi_document_text: string): string; | ||
| 5 | |||
| 6 | export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module; | ||
| 7 | |||
| 8 | export interface InitOutput { | ||
| 9 | readonly memory: WebAssembly.Memory; | ||
| 10 | readonly extract_manifest_json_wasm: (a: number, b: number, c: number, d: number) => [number, number, number, number]; | ||
| 11 | readonly __wbindgen_externrefs: WebAssembly.Table; | ||
| 12 | readonly __wbindgen_malloc: (a: number, b: number) => number; | ||
| 13 | readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number; | ||
| 14 | readonly __externref_table_dealloc: (a: number) => void; | ||
| 15 | readonly __wbindgen_free: (a: number, b: number, c: number) => void; | ||
| 16 | readonly __wbindgen_start: () => void; | ||
| 17 | } | ||
| 18 | |||
| 19 | export type SyncInitInput = BufferSource | WebAssembly.Module; | ||
| 20 | |||
| 21 | /** | ||
| 22 | * Instantiates the given `module`, which can either be bytes or | ||
| 23 | * a precompiled `WebAssembly.Module`. | ||
| 24 | * | ||
| 25 | * @param {{ module: SyncInitInput }} module - Passing `SyncInitInput` directly is deprecated. | ||
| 26 | * | ||
| 27 | * @returns {InitOutput} | ||
| 28 | */ | ||
| 29 | export function initSync(module: { module: SyncInitInput } | SyncInitInput): InitOutput; | ||
| 30 | |||
| 31 | /** | ||
| 32 | * If `module_or_path` is {RequestInfo} or {URL}, makes a request and | ||
| 33 | * for everything else, calls `WebAssembly.instantiate` directly. | ||
| 34 | * | ||
| 35 | * @param {{ module_or_path: InitInput | Promise<InitInput> }} module_or_path - Passing `InitInput` directly is deprecated. | ||
| 36 | * | ||
| 37 | * @returns {Promise<InitOutput>} | ||
| 38 | */ | ||
| 39 | export default function __wbg_init (module_or_path?: { module_or_path: InitInput | Promise<InitInput> } | InitInput | Promise<InitInput>): Promise<InitOutput>; | ||
| @@ -0,0 +1,230 @@ | |||
| 1 | /* @ts-self-types="./openapi_extractor.d.ts" */ | ||
| 2 | |||
| 3 | /** | ||
| 4 | * @param {string} source_name | ||
| 5 | * @param {string} openapi_document_text | ||
| 6 | * @returns {string} | ||
| 7 | */ | ||
| 8 | export function extract_manifest_json_wasm(source_name, openapi_document_text) { | ||
| 9 | let deferred4_0; | ||
| 10 | let deferred4_1; | ||
| 11 | try { | ||
| 12 | const ptr0 = passStringToWasm0(source_name, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); | ||
| 13 | const len0 = WASM_VECTOR_LEN; | ||
| 14 | const ptr1 = passStringToWasm0(openapi_document_text, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); | ||
| 15 | const len1 = WASM_VECTOR_LEN; | ||
| 16 | const ret = wasm.extract_manifest_json_wasm(ptr0, len0, ptr1, len1); | ||
| 17 | var ptr3 = ret[0]; | ||
| 18 | var len3 = ret[1]; | ||
| 19 | if (ret[3]) { | ||
| 20 | ptr3 = 0; len3 = 0; | ||
| 21 | throw takeFromExternrefTable0(ret[2]); | ||
| 22 | } | ||
| 23 | deferred4_0 = ptr3; | ||
| 24 | deferred4_1 = len3; | ||
| 25 | return getStringFromWasm0(ptr3, len3); | ||
| 26 | } finally { | ||
| 27 | wasm.__wbindgen_free(deferred4_0, deferred4_1, 1); | ||
| 28 | } | ||
| 29 | } | ||
| 30 | |||
| 31 | function __wbg_get_imports() { | ||
| 32 | const import0 = { | ||
| 33 | __proto__: null, | ||
| 34 | __wbindgen_cast_0000000000000001: function(arg0, arg1) { | ||
| 35 | // Cast intrinsic for `Ref(String) -> Externref`. | ||
| 36 | const ret = getStringFromWasm0(arg0, arg1); | ||
| 37 | return ret; | ||
| 38 | }, | ||
| 39 | __wbindgen_init_externref_table: function() { | ||
| 40 | const table = wasm.__wbindgen_externrefs; | ||
| 41 | const offset = table.grow(4); | ||
| 42 | table.set(0, undefined); | ||
| 43 | table.set(offset + 0, undefined); | ||
| 44 | table.set(offset + 1, null); | ||
| 45 | table.set(offset + 2, true); | ||
| 46 | table.set(offset + 3, false); | ||
| 47 | }, | ||
| 48 | }; | ||
| 49 | return { | ||
| 50 | __proto__: null, | ||
| 51 | "./openapi_extractor_bg.js": import0, | ||
| 52 | }; | ||
| 53 | } | ||
| 54 | |||
| 55 | function getStringFromWasm0(ptr, len) { | ||
| 56 | ptr = ptr >>> 0; | ||
| 57 | return decodeText(ptr, len); | ||
| 58 | } | ||
| 59 | |||
| 60 | let cachedUint8ArrayMemory0 = null; | ||
| 61 | function getUint8ArrayMemory0() { | ||
| 62 | if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) { | ||
| 63 | cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer); | ||
| 64 | } | ||
| 65 | return cachedUint8ArrayMemory0; | ||
| 66 | } | ||
| 67 | |||
| 68 | function passStringToWasm0(arg, malloc, realloc) { | ||
| 69 | if (realloc === undefined) { | ||
| 70 | const buf = cachedTextEncoder.encode(arg); | ||
| 71 | const ptr = malloc(buf.length, 1) >>> 0; | ||
| 72 | getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf); | ||
| 73 | WASM_VECTOR_LEN = buf.length; | ||
| 74 | return ptr; | ||
| 75 | } | ||
| 76 | |||
| 77 | let len = arg.length; | ||
| 78 | let ptr = malloc(len, 1) >>> 0; | ||
| 79 | |||
| 80 | const mem = getUint8ArrayMemory0(); | ||
| 81 | |||
| 82 | let offset = 0; | ||
| 83 | |||
| 84 | for (; offset < len; offset++) { | ||
| 85 | const code = arg.charCodeAt(offset); | ||
| 86 | if (code > 0x7F) break; | ||
| 87 | mem[ptr + offset] = code; | ||
| 88 | } | ||
| 89 | if (offset !== len) { | ||
| 90 | if (offset !== 0) { | ||
| 91 | arg = arg.slice(offset); | ||
| 92 | } | ||
| 93 | ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0; | ||
| 94 | const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len); | ||
| 95 | const ret = cachedTextEncoder.encodeInto(arg, view); | ||
| 96 | |||
| 97 | offset += ret.written; | ||
| 98 | ptr = realloc(ptr, len, offset, 1) >>> 0; | ||
| 99 | } | ||
| 100 | |||
| 101 | WASM_VECTOR_LEN = offset; | ||
| 102 | return ptr; | ||
| 103 | } | ||
| 104 | |||
| 105 | function takeFromExternrefTable0(idx) { | ||
| 106 | const value = wasm.__wbindgen_externrefs.get(idx); | ||
| 107 | wasm.__externref_table_dealloc(idx); | ||
| 108 | return value; | ||
| 109 | } | ||
| 110 | |||
| 111 | let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); | ||
| 112 | cachedTextDecoder.decode(); | ||
| 113 | const MAX_SAFARI_DECODE_BYTES = 2146435072; | ||
| 114 | let numBytesDecoded = 0; | ||
| 115 | function decodeText(ptr, len) { | ||
| 116 | numBytesDecoded += len; | ||
| 117 | if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) { | ||
| 118 | cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); | ||
| 119 | cachedTextDecoder.decode(); | ||
| 120 | numBytesDecoded = len; | ||
| 121 | } | ||
| 122 | return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len)); | ||
| 123 | } | ||
| 124 | |||
| 125 | const cachedTextEncoder = new TextEncoder(); | ||
| 126 | |||
| 127 | if (!('encodeInto' in cachedTextEncoder)) { | ||
| 128 | cachedTextEncoder.encodeInto = function (arg, view) { | ||
| 129 | const buf = cachedTextEncoder.encode(arg); | ||
| 130 | view.set(buf); | ||
| 131 | return { | ||
| 132 | read: arg.length, | ||
| 133 | written: buf.length | ||
| 134 | }; | ||
| 135 | }; | ||
| 136 | } | ||
| 137 | |||
| 138 | let WASM_VECTOR_LEN = 0; | ||
| 139 | |||
| 140 | let wasmModule, wasm; | ||
| 141 | function __wbg_finalize_init(instance, module) { | ||
| 142 | wasm = instance.exports; | ||
| 143 | wasmModule = module; | ||
| 144 | cachedUint8ArrayMemory0 = null; | ||
| 145 | wasm.__wbindgen_start(); | ||
| 146 | return wasm; | ||
| 147 | } | ||
| 148 | |||
| 149 | async function __wbg_load(module, imports) { | ||
| 150 | if (typeof Response === 'function' && module instanceof Response) { | ||
| 151 | if (typeof WebAssembly.instantiateStreaming === 'function') { | ||
| 152 | try { | ||
| 153 | return await WebAssembly.instantiateStreaming(module, imports); | ||
| 154 | } catch (e) { | ||
| 155 | const validResponse = module.ok && expectedResponseType(module.type); | ||
| 156 | |||
| 157 | if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') { | ||
| 158 | console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e); | ||
| 159 | |||
| 160 | } else { throw e; } | ||
| 161 | } | ||
| 162 | } | ||
| 163 | |||
| 164 | const bytes = await module.arrayBuffer(); | ||
| 165 | return await WebAssembly.instantiate(bytes, imports); | ||
| 166 | } else { | ||
| 167 | const instance = await WebAssembly.instantiate(module, imports); | ||
| 168 | |||
| 169 | if (instance instanceof WebAssembly.Instance) { | ||
| 170 | return { instance, module }; | ||
| 171 | } else { | ||
| 172 | return instance; | ||
| 173 | } | ||
| 174 | } | ||
| 175 | |||
| 176 | function expectedResponseType(type) { | ||
| 177 | switch (type) { | ||
| 178 | case 'basic': case 'cors': case 'default': return true; | ||
| 179 | } | ||
| 180 | return false; | ||
| 181 | } | ||
| 182 | } | ||
| 183 | |||
| 184 | function initSync(module) { | ||
| 185 | if (wasm !== undefined) return wasm; | ||
| 186 | |||
| 187 | |||
| 188 | if (module !== undefined) { | ||
| 189 | if (Object.getPrototypeOf(module) === Object.prototype) { | ||
| 190 | ({module} = module) | ||
| 191 | } else { | ||
| 192 | console.warn('using deprecated parameters for `initSync()`; pass a single object instead') | ||
| 193 | } | ||
| 194 | } | ||
| 195 | |||
| 196 | const imports = __wbg_get_imports(); | ||
| 197 | if (!(module instanceof WebAssembly.Module)) { | ||
| 198 | module = new WebAssembly.Module(module); | ||
| 199 | } | ||
| 200 | const instance = new WebAssembly.Instance(module, imports); | ||
| 201 | return __wbg_finalize_init(instance, module); | ||
| 202 | } | ||
| 203 | |||
| 204 | async function __wbg_init(module_or_path) { | ||
| 205 | if (wasm !== undefined) return wasm; | ||
| 206 | |||
| 207 | |||
| 208 | if (module_or_path !== undefined) { | ||
| 209 | if (Object.getPrototypeOf(module_or_path) === Object.prototype) { | ||
| 210 | ({module_or_path} = module_or_path) | ||
| 211 | } else { | ||
| 212 | console.warn('using deprecated parameters for the initialization function; pass a single object instead') | ||
| 213 | } | ||
| 214 | } | ||
| 215 | |||
| 216 | if (module_or_path === undefined) { | ||
| 217 | module_or_path = new URL('openapi_extractor_bg.wasm', import.meta.url); | ||
| 218 | } | ||
| 219 | const imports = __wbg_get_imports(); | ||
| 220 | |||
| 221 | if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) { | ||
| 222 | module_or_path = fetch(module_or_path); | ||
| 223 | } | ||
| 224 | |||
| 225 | const { instance, module } = await __wbg_load(await module_or_path, imports); | ||
| 226 | |||
| 227 | return __wbg_finalize_init(instance, module); | ||
| 228 | } | ||
| 229 | |||
| 230 | export { initSync, __wbg_init as default }; | ||
Binary file not shown.
| @@ -0,0 +1,10 @@ | |||
| 1 | /* tslint:disable */ | ||
| 2 | /* eslint-disable */ | ||
| 3 | export const memory: WebAssembly.Memory; | ||
| 4 | export const extract_manifest_json_wasm: (a: number, b: number, c: number, d: number) => [number, number, number, number]; | ||
| 5 | export const __wbindgen_externrefs: WebAssembly.Table; | ||
| 6 | export const __wbindgen_malloc: (a: number, b: number) => number; | ||
| 7 | export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number; | ||
| 8 | export const __externref_table_dealloc: (a: number) => void; | ||
| 9 | export const __wbindgen_free: (a: number, b: number, c: number) => void; | ||
| 10 | export const __wbindgen_start: () => void; | ||
| @@ -0,0 +1 @@ | |||
| 1 | target/ | ||
| @@ -0,0 +1,445 @@ | |||
| 1 | # This file is automatically @generated by Cargo. | ||
| 2 | # It is not intended for manual editing. | ||
| 3 | version = 4 | ||
| 4 | |||
| 5 | [[package]] | ||
| 6 | name = "anstream" | ||
| 7 | version = "0.6.21" | ||
| 8 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 9 | checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" | ||
| 10 | dependencies = [ | ||
| 11 | "anstyle", | ||
| 12 | "anstyle-parse", | ||
| 13 | "anstyle-query", | ||
| 14 | "anstyle-wincon", | ||
| 15 | "colorchoice", | ||
| 16 | "is_terminal_polyfill", | ||
| 17 | "utf8parse", | ||
| 18 | ] | ||
| 19 | |||
| 20 | [[package]] | ||
| 21 | name = "anstyle" | ||
| 22 | version = "1.0.13" | ||
| 23 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 24 | checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" | ||
| 25 | |||
| 26 | [[package]] | ||
| 27 | name = "anstyle-parse" | ||
| 28 | version = "0.2.7" | ||
| 29 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 30 | checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" | ||
| 31 | dependencies = [ | ||
| 32 | "utf8parse", | ||
| 33 | ] | ||
| 34 | |||
| 35 | [[package]] | ||
| 36 | name = "anstyle-query" | ||
| 37 | version = "1.1.5" | ||
| 38 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 39 | checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" | ||
| 40 | dependencies = [ | ||
| 41 | "windows-sys", | ||
| 42 | ] | ||
| 43 | |||
| 44 | [[package]] | ||
| 45 | name = "anstyle-wincon" | ||
| 46 | version = "3.0.11" | ||
| 47 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 48 | checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" | ||
| 49 | dependencies = [ | ||
| 50 | "anstyle", | ||
| 51 | "once_cell_polyfill", | ||
| 52 | "windows-sys", | ||
| 53 | ] | ||
| 54 | |||
| 55 | [[package]] | ||
| 56 | name = "block-buffer" | ||
| 57 | version = "0.10.4" | ||
| 58 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 59 | checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" | ||
| 60 | dependencies = [ | ||
| 61 | "generic-array", | ||
| 62 | ] | ||
| 63 | |||
| 64 | [[package]] | ||
| 65 | name = "bumpalo" | ||
| 66 | version = "3.20.2" | ||
| 67 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 68 | checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" | ||
| 69 | |||
| 70 | [[package]] | ||
| 71 | name = "cfg-if" | ||
| 72 | version = "1.0.4" | ||
| 73 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 74 | checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" | ||
| 75 | |||
| 76 | [[package]] | ||
| 77 | name = "clap" | ||
| 78 | version = "4.5.60" | ||
| 79 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 80 | checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" | ||
| 81 | dependencies = [ | ||
| 82 | "clap_builder", | ||
| 83 | "clap_derive", | ||
| 84 | ] | ||
| 85 | |||
| 86 | [[package]] | ||
| 87 | name = "clap_builder" | ||
| 88 | version = "4.5.60" | ||
| 89 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 90 | checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" | ||
| 91 | dependencies = [ | ||
| 92 | "anstream", | ||
| 93 | "anstyle", | ||
| 94 | "clap_lex", | ||
| 95 | "strsim", | ||
| 96 | ] | ||
| 97 | |||
| 98 | [[package]] | ||
| 99 | name = "clap_derive" | ||
| 100 | version = "4.5.55" | ||
| 101 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 102 | checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" | ||
| 103 | dependencies = [ | ||
| 104 | "heck", | ||
| 105 | "proc-macro2", | ||
| 106 | "quote", | ||
| 107 | "syn", | ||
| 108 | ] | ||
| 109 | |||
| 110 | [[package]] | ||
| 111 | name = "clap_lex" | ||
| 112 | version = "1.0.0" | ||
| 113 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 114 | checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" | ||
| 115 | |||
| 116 | [[package]] | ||
| 117 | name = "colorchoice" | ||
| 118 | version = "1.0.4" | ||
| 119 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 120 | checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" | ||
| 121 | |||
| 122 | [[package]] | ||
| 123 | name = "cpufeatures" | ||
| 124 | version = "0.2.17" | ||
| 125 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 126 | checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" | ||
| 127 | dependencies = [ | ||
| 128 | "libc", | ||
| 129 | ] | ||
| 130 | |||
| 131 | [[package]] | ||
| 132 | name = "crypto-common" | ||
| 133 | version = "0.1.7" | ||
| 134 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 135 | checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" | ||
| 136 | dependencies = [ | ||
| 137 | "generic-array", | ||
| 138 | "typenum", | ||
| 139 | ] | ||
| 140 | |||
| 141 | [[package]] | ||
| 142 | name = "digest" | ||
| 143 | version = "0.10.7" | ||
| 144 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 145 | checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" | ||
| 146 | dependencies = [ | ||
| 147 | "block-buffer", | ||
| 148 | "crypto-common", | ||
| 149 | ] | ||
| 150 | |||
| 151 | [[package]] | ||
| 152 | name = "equivalent" | ||
| 153 | version = "1.0.2" | ||
| 154 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 155 | checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" | ||
| 156 | |||
| 157 | [[package]] | ||
| 158 | name = "generic-array" | ||
| 159 | version = "0.14.7" | ||
| 160 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 161 | checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" | ||
| 162 | dependencies = [ | ||
| 163 | "typenum", | ||
| 164 | "version_check", | ||
| 165 | ] | ||
| 166 | |||
| 167 | [[package]] | ||
| 168 | name = "hashbrown" | ||
| 169 | version = "0.16.1" | ||
| 170 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 171 | checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" | ||
| 172 | |||
| 173 | [[package]] | ||
| 174 | name = "heck" | ||
| 175 | version = "0.5.0" | ||
| 176 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 177 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" | ||
| 178 | |||
| 179 | [[package]] | ||
| 180 | name = "indexmap" | ||
| 181 | version = "2.13.0" | ||
| 182 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 183 | checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" | ||
| 184 | dependencies = [ | ||
| 185 | "equivalent", | ||
| 186 | "hashbrown", | ||
| 187 | ] | ||
| 188 | |||
| 189 | [[package]] | ||
| 190 | name = "is_terminal_polyfill" | ||
| 191 | version = "1.70.2" | ||
| 192 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 193 | checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" | ||
| 194 | |||
| 195 | [[package]] | ||
| 196 | name = "itoa" | ||
| 197 | version = "1.0.17" | ||
| 198 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 199 | checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" | ||
| 200 | |||
| 201 | [[package]] | ||
| 202 | name = "libc" | ||
| 203 | version = "0.2.182" | ||
| 204 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 205 | checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" | ||
| 206 | |||
| 207 | [[package]] | ||
| 208 | name = "memchr" | ||
| 209 | version = "2.8.0" | ||
| 210 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 211 | checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" | ||
| 212 | |||
| 213 | [[package]] | ||
| 214 | name = "once_cell" | ||
| 215 | version = "1.21.3" | ||
| 216 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 217 | checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" | ||
| 218 | |||
| 219 | [[package]] | ||
| 220 | name = "once_cell_polyfill" | ||
| 221 | version = "1.70.2" | ||
| 222 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 223 | checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" | ||
| 224 | |||
| 225 | [[package]] | ||
| 226 | name = "openapi-extractor-rs" | ||
| 227 | version = "0.1.0" | ||
| 228 | dependencies = [ | ||
| 229 | "clap", | ||
| 230 | "serde", | ||
| 231 | "serde_json", | ||
| 232 | "serde_yaml", | ||
| 233 | "sha2", | ||
| 234 | "wasm-bindgen", | ||
| 235 | ] | ||
| 236 | |||
| 237 | [[package]] | ||
| 238 | name = "proc-macro2" | ||
| 239 | version = "1.0.106" | ||
| 240 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 241 | checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" | ||
| 242 | dependencies = [ | ||
| 243 | "unicode-ident", | ||
| 244 | ] | ||
| 245 | |||
| 246 | [[package]] | ||
| 247 | name = "quote" | ||
| 248 | version = "1.0.44" | ||
| 249 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 250 | checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" | ||
| 251 | dependencies = [ | ||
| 252 | "proc-macro2", | ||
| 253 | ] | ||
| 254 | |||
| 255 | [[package]] | ||
| 256 | name = "rustversion" | ||
| 257 | version = "1.0.22" | ||
| 258 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 259 | checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" | ||
| 260 | |||
| 261 | [[package]] | ||
| 262 | name = "ryu" | ||
| 263 | version = "1.0.23" | ||
| 264 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 265 | checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" | ||
| 266 | |||
| 267 | [[package]] | ||
| 268 | name = "serde" | ||
| 269 | version = "1.0.228" | ||
| 270 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 271 | checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" | ||
| 272 | dependencies = [ | ||
| 273 | "serde_core", | ||
| 274 | "serde_derive", | ||
| 275 | ] | ||
| 276 | |||
| 277 | [[package]] | ||
| 278 | name = "serde_core" | ||
| 279 | version = "1.0.228" | ||
| 280 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 281 | checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" | ||
| 282 | dependencies = [ | ||
| 283 | "serde_derive", | ||
| 284 | ] | ||
| 285 | |||
| 286 | [[package]] | ||
| 287 | name = "serde_derive" | ||
| 288 | version = "1.0.228" | ||
| 289 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 290 | checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" | ||
| 291 | dependencies = [ | ||
| 292 | "proc-macro2", | ||
| 293 | "quote", | ||
| 294 | "syn", | ||
| 295 | ] | ||
| 296 | |||
| 297 | [[package]] | ||
| 298 | name = "serde_json" | ||
| 299 | version = "1.0.149" | ||
| 300 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 301 | checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" | ||
| 302 | dependencies = [ | ||
| 303 | "itoa", | ||
| 304 | "memchr", | ||
| 305 | "serde", | ||
| 306 | "serde_core", | ||
| 307 | "zmij", | ||
| 308 | ] | ||
| 309 | |||
| 310 | [[package]] | ||
| 311 | name = "serde_yaml" | ||
| 312 | version = "0.9.34+deprecated" | ||
| 313 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 314 | checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" | ||
| 315 | dependencies = [ | ||
| 316 | "indexmap", | ||
| 317 | "itoa", | ||
| 318 | "ryu", | ||
| 319 | "serde", | ||
| 320 | "unsafe-libyaml", | ||
| 321 | ] | ||
| 322 | |||
| 323 | [[package]] | ||
| 324 | name = "sha2" | ||
| 325 | version = "0.10.9" | ||
| 326 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 327 | checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" | ||
| 328 | dependencies = [ | ||
| 329 | "cfg-if", | ||
| 330 | "cpufeatures", | ||
| 331 | "digest", | ||
| 332 | ] | ||
| 333 | |||
| 334 | [[package]] | ||
| 335 | name = "strsim" | ||
| 336 | version = "0.11.1" | ||
| 337 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 338 | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" | ||
| 339 | |||
| 340 | [[package]] | ||
| 341 | name = "syn" | ||
| 342 | version = "2.0.117" | ||
| 343 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 344 | checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" | ||
| 345 | dependencies = [ | ||
| 346 | "proc-macro2", | ||
| 347 | "quote", | ||
| 348 | "unicode-ident", | ||
| 349 | ] | ||
| 350 | |||
| 351 | [[package]] | ||
| 352 | name = "typenum" | ||
| 353 | version = "1.19.0" | ||
| 354 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 355 | checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" | ||
| 356 | |||
| 357 | [[package]] | ||
| 358 | name = "unicode-ident" | ||
| 359 | version = "1.0.24" | ||
| 360 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 361 | checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" | ||
| 362 | |||
| 363 | [[package]] | ||
| 364 | name = "unsafe-libyaml" | ||
| 365 | version = "0.2.11" | ||
| 366 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 367 | checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" | ||
| 368 | |||
| 369 | [[package]] | ||
| 370 | name = "utf8parse" | ||
| 371 | version = "0.2.2" | ||
| 372 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 373 | checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" | ||
| 374 | |||
| 375 | [[package]] | ||
| 376 | name = "version_check" | ||
| 377 | version = "0.9.5" | ||
| 378 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 379 | checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" | ||
| 380 | |||
| 381 | [[package]] | ||
| 382 | name = "wasm-bindgen" | ||
| 383 | version = "0.2.114" | ||
| 384 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 385 | checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" | ||
| 386 | dependencies = [ | ||
| 387 | "cfg-if", | ||
| 388 | "once_cell", | ||
| 389 | "rustversion", | ||
| 390 | "wasm-bindgen-macro", | ||
| 391 | "wasm-bindgen-shared", | ||
| 392 | ] | ||
| 393 | |||
| 394 | [[package]] | ||
| 395 | name = "wasm-bindgen-macro" | ||
| 396 | version = "0.2.114" | ||
| 397 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 398 | checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" | ||
| 399 | dependencies = [ | ||
| 400 | "quote", | ||
| 401 | "wasm-bindgen-macro-support", | ||
| 402 | ] | ||
| 403 | |||
| 404 | [[package]] | ||
| 405 | name = "wasm-bindgen-macro-support" | ||
| 406 | version = "0.2.114" | ||
| 407 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 408 | checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" | ||
| 409 | dependencies = [ | ||
| 410 | "bumpalo", | ||
| 411 | "proc-macro2", | ||
| 412 | "quote", | ||
| 413 | "syn", | ||
| 414 | "wasm-bindgen-shared", | ||
| 415 | ] | ||
| 416 | |||
| 417 | [[package]] | ||
| 418 | name = "wasm-bindgen-shared" | ||
| 419 | version = "0.2.114" | ||
| 420 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 421 | checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" | ||
| 422 | dependencies = [ | ||
| 423 | "unicode-ident", | ||
| 424 | ] | ||
| 425 | |||
| 426 | [[package]] | ||
| 427 | name = "windows-link" | ||
| 428 | version = "0.2.1" | ||
| 429 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 430 | checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" | ||
| 431 | |||
| 432 | [[package]] | ||
| 433 | name = "windows-sys" | ||
| 434 | version = "0.61.2" | ||
| 435 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 436 | checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" | ||
| 437 | dependencies = [ | ||
| 438 | "windows-link", | ||
| 439 | ] | ||
| 440 | |||
| 441 | [[package]] | ||
| 442 | name = "zmij" | ||
| 443 | version = "1.0.21" | ||
| 444 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 445 | checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" | ||
| @@ -0,0 +1,17 @@ | |||
| 1 | [package] | ||
| 2 | name = "openapi-extractor-rs" | ||
| 3 | version = "0.1.0" | ||
| 4 | edition = "2024" | ||
| 5 | |||
| 6 | [lib] | ||
| 7 | crate-type = ["cdylib", "rlib"] | ||
| 8 | |||
| 9 | [dependencies] | ||
| 10 | clap = { version = "4.5.40", features = ["derive"] } | ||
| 11 | serde = { version = "1.0.228", features = ["derive"] } | ||
| 12 | serde_json = "1.0.145" | ||
| 13 | serde_yaml = "0.9.34" | ||
| 14 | sha2 = "0.10.9" | ||
| 15 | |||
| 16 | [target.'cfg(target_arch = "wasm32")'.dependencies] | ||
| 17 | wasm-bindgen = "0.2.114" | ||
| @@ -0,0 +1,33 @@ | |||
| 1 | # openapi-extractor-rs | ||
| 2 | |||
| 3 | Rust implementation of the OpenAPI extraction pipeline used by `packages/management-api/src/openapi-extraction.ts`. | ||
| 4 | |||
| 5 | ## Build | ||
| 6 | |||
| 7 | ```bash | ||
| 8 | cargo build --release | ||
| 9 | ``` | ||
| 10 | |||
| 11 | ## Build WASM Bindings For Runtime | ||
| 12 | |||
| 13 | This is the production path used by `@executor-v2/management-api`. | ||
| 14 | |||
| 15 | ```bash | ||
| 16 | ./build-wasm.sh | ||
| 17 | ``` | ||
| 18 | |||
| 19 | The script compiles `wasm32-unknown-unknown` and writes bindings/artifacts to: | ||
| 20 | |||
| 21 | - `packages/management-api/src/openapi-extractor-wasm/openapi_extractor.js` | ||
| 22 | - `packages/management-api/src/openapi-extractor-wasm/openapi_extractor_bg.wasm` | ||
| 23 | |||
| 24 | ## Run | ||
| 25 | |||
| 26 | ```bash | ||
| 27 | ./target/release/openapi-extractor-rs \ | ||
| 28 | --source-name "Cloudflare API" \ | ||
| 29 | --input /tmp/cloudflare-openapi.yaml \ | ||
| 30 | --output /tmp/manifest-rs.json | ||
| 31 | ``` | ||
| 32 | |||
| 33 | Use `--input -` to read the OpenAPI document from stdin. Use `--pretty` for formatted JSON output. | ||
| @@ -0,0 +1,20 @@ | |||
| 1 | #!/usr/bin/env bash | ||
| 2 | set -euo pipefail | ||
| 3 | |||
| 4 | ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)" | ||
| 5 | CRATE_DIR="$ROOT_DIR/tools/openapi-extractor-rs" | ||
| 6 | OUT_DIR="$ROOT_DIR/packages/management-api/src/openapi-extractor-wasm" | ||
| 7 | |||
| 8 | cd "$CRATE_DIR" | ||
| 9 | |||
| 10 | rustup target add wasm32-unknown-unknown >/dev/null | ||
| 11 | |||
| 12 | cargo build --release --target wasm32-unknown-unknown | ||
| 13 | |||
| 14 | wasm-bindgen \ | ||
| 15 | --target web \ | ||
| 16 | --out-dir "$OUT_DIR" \ | ||
| 17 | --out-name openapi_extractor \ | ||
| 18 | "$CRATE_DIR/target/wasm32-unknown-unknown/release/openapi_extractor_rs.wasm" | ||
| 19 | |||
| 20 | rm -rf "$CRATE_DIR/target" | ||
| @@ -0,0 +1,690 @@ | |||
| 1 | use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque}; | ||
| 2 | |||
| 3 | use serde::Serialize; | ||
| 4 | use serde_json::{Map, Value, json}; | ||
| 5 | use sha2::{Digest, Sha256}; | ||
| 6 | |||
| 7 | #[cfg(target_arch = "wasm32")] | ||
| 8 | use wasm_bindgen::prelude::*; | ||
| 9 | |||
| 10 | const OPEN_API_HTTP_METHODS: [&str; 8] = [ | ||
| 11 | "get", | ||
| 12 | "put", | ||
| 13 | "post", | ||
| 14 | "delete", | ||
| 15 | "patch", | ||
| 16 | "head", | ||
| 17 | "options", | ||
| 18 | "trace", | ||
| 19 | ]; | ||
| 20 | |||
| 21 | const OPEN_API_PARAMETER_LOCATIONS: [&str; 4] = ["path", "query", "header", "cookie"]; | ||
| 22 | |||
| 23 | #[derive(Serialize, Clone)] | ||
| 24 | struct OpenApiToolParameter { | ||
| 25 | name: String, | ||
| 26 | location: String, | ||
| 27 | required: bool, | ||
| 28 | } | ||
| 29 | |||
| 30 | #[derive(Serialize, Clone)] | ||
| 31 | #[serde(rename_all = "camelCase")] | ||
| 32 | struct OpenApiToolRequestBody { | ||
| 33 | required: bool, | ||
| 34 | content_types: Vec<String>, | ||
| 35 | } | ||
| 36 | |||
| 37 | #[derive(Serialize, Clone)] | ||
| 38 | #[serde(rename_all = "camelCase")] | ||
| 39 | struct OpenApiInvocationPayload { | ||
| 40 | method: String, | ||
| 41 | path_template: String, | ||
| 42 | parameters: Vec<OpenApiToolParameter>, | ||
| 43 | request_body: Option<OpenApiToolRequestBody>, | ||
| 44 | } | ||
| 45 | |||
| 46 | #[derive(Serialize, Clone)] | ||
| 47 | #[serde(rename_all = "camelCase")] | ||
| 48 | struct DiscoveryTypingPayload { | ||
| 49 | #[serde(skip_serializing_if = "Option::is_none")] | ||
| 50 | input_schema_json: Option<String>, | ||
| 51 | #[serde(skip_serializing_if = "Option::is_none")] | ||
| 52 | output_schema_json: Option<String>, | ||
| 53 | #[serde(skip_serializing_if = "Option::is_none")] | ||
| 54 | ref_hint_keys: Option<Vec<String>>, | ||
| 55 | } | ||
| 56 | |||
| 57 | #[derive(Serialize, Clone)] | ||
| 58 | #[serde(rename_all = "camelCase")] | ||
| 59 | struct OpenApiExtractedTool { | ||
| 60 | tool_id: String, | ||
| 61 | name: String, | ||
| 62 | description: Option<String>, | ||
| 63 | method: String, | ||
| 64 | path: String, | ||
| 65 | invocation: OpenApiInvocationPayload, | ||
| 66 | operation_hash: String, | ||
| 67 | #[serde(skip_serializing_if = "Option::is_none")] | ||
| 68 | typing: Option<DiscoveryTypingPayload>, | ||
| 69 | } | ||
| 70 | |||
| 71 | #[derive(Serialize)] | ||
| 72 | #[serde(rename_all = "camelCase")] | ||
| 73 | struct OpenApiToolManifest { | ||
| 74 | version: u8, | ||
| 75 | source_hash: String, | ||
| 76 | tools: Vec<OpenApiExtractedTool>, | ||
| 77 | #[serde(skip_serializing_if = "Option::is_none")] | ||
| 78 | ref_hint_table: Option<BTreeMap<String, String>>, | ||
| 79 | } | ||
| 80 | |||
| 81 | pub fn extract_manifest_json( | ||
| 82 | source_name: &str, | ||
| 83 | openapi_document_text: &str, | ||
| 84 | pretty: bool, | ||
| 85 | ) -> Result<String, String> { | ||
| 86 | let spec = parse_openapi_document(openapi_document_text)?; | ||
| 87 | let manifest = extract_openapi_manifest(source_name, &spec)?; | ||
| 88 | |||
| 89 | if pretty { | ||
| 90 | serde_json::to_string_pretty(&manifest).map_err(|error| format!("serialize manifest: {error}")) | ||
| 91 | } else { | ||
| 92 | serde_json::to_string(&manifest).map_err(|error| format!("serialize manifest: {error}")) | ||
| 93 | } | ||
| 94 | } | ||
| 95 | |||
| 96 | #[cfg(target_arch = "wasm32")] | ||
| 97 | #[wasm_bindgen] | ||
| 98 | pub fn extract_manifest_json_wasm( | ||
| 99 | source_name: &str, | ||
| 100 | openapi_document_text: &str, | ||
| 101 | ) -> Result<String, JsValue> { | ||
| 102 | extract_manifest_json(source_name, openapi_document_text, false) | ||
| 103 | .map_err(|error| JsValue::from_str(&error)) | ||
| 104 | } | ||
| 105 | |||
| 106 | fn parse_openapi_document(input: &str) -> Result<Value, String> { | ||
| 107 | let text = input.trim(); | ||
| 108 | if text.is_empty() { | ||
| 109 | return Err("OpenAPI document is empty".to_string()); | ||
| 110 | } | ||
| 111 | |||
| 112 | match serde_json::from_str::<Value>(text) { | ||
| 113 | Ok(value) => Ok(value), | ||
| 114 | Err(_) => serde_yaml::from_str::<Value>(text) | ||
| 115 | .map_err(|error| format!("Unable to parse OpenAPI document as JSON or YAML: {error}")), | ||
| 116 | } | ||
| 117 | } | ||
| 118 | |||
| 119 | fn as_object(value: &Value) -> Option<&Map<String, Value>> { | ||
| 120 | value.as_object() | ||
| 121 | } | ||
| 122 | |||
| 123 | fn as_trimmed_string(value: Option<&Value>) -> Option<String> { | ||
| 124 | let candidate = value?.as_str()?.trim(); | ||
| 125 | if candidate.is_empty() { | ||
| 126 | return None; | ||
| 127 | } | ||
| 128 | |||
| 129 | Some(candidate.to_string()) | ||
| 130 | } | ||
| 131 | |||
| 132 | fn response_status_rank(status_code: &str) -> i32 { | ||
| 133 | if status_code.len() == 3 | ||
| 134 | && status_code.starts_with('2') | ||
| 135 | && status_code.chars().all(|character| character.is_ascii_digit()) | ||
| 136 | { | ||
| 137 | return 0; | ||
| 138 | } | ||
| 139 | |||
| 140 | if status_code == "default" { | ||
| 141 | return 1; | ||
| 142 | } | ||
| 143 | |||
| 144 | 2 | ||
| 145 | } | ||
| 146 | |||
| 147 | fn to_stable_value(value: &Value) -> Value { | ||
| 148 | match value { | ||
| 149 | Value::Array(items) => Value::Array(items.iter().map(to_stable_value).collect()), | ||
| 150 | Value::Object(object) => { | ||
| 151 | let mut keys: Vec<&String> = object.keys().collect(); | ||
| 152 | keys.sort(); | ||
| 153 | |||
| 154 | let mut stable = Map::new(); | ||
| 155 | for key in keys { | ||
| 156 | if let Some(item) = object.get(key) { | ||
| 157 | stable.insert(key.clone(), to_stable_value(item)); | ||
| 158 | } | ||
| 159 | } | ||
| 160 | |||
| 161 | Value::Object(stable) | ||
| 162 | } | ||
| 163 | Value::Number(number) => { | ||
| 164 | if let Some(float_value) = number.as_f64() | ||
| 165 | && float_value.is_finite() | ||
| 166 | && float_value.fract() == 0.0 | ||
| 167 | { | ||
| 168 | if float_value >= 0.0 && float_value <= u64::MAX as f64 { | ||
| 169 | let unsigned = float_value as u64; | ||
| 170 | if unsigned as f64 == float_value { | ||
| 171 | return Value::Number(serde_json::Number::from(unsigned)); | ||
| 172 | } | ||
| 173 | } | ||
| 174 | |||
| 175 | if float_value >= i64::MIN as f64 && float_value <= i64::MAX as f64 { | ||
| 176 | let signed = float_value as i64; | ||
| 177 | if signed as f64 == float_value { | ||
| 178 | return Value::Number(serde_json::Number::from(signed)); | ||
| 179 | } | ||
| 180 | } | ||
| 181 | } | ||
| 182 | |||
| 183 | Value::Number(number.clone()) | ||
| 184 | } | ||
| 185 | _ => value.clone(), | ||
| 186 | } | ||
| 187 | } | ||
| 188 | |||
| 189 | fn hash_unknown(value: &Value) -> Result<String, String> { | ||
| 190 | let stable_json = | ||
| 191 | serde_json::to_string(&to_stable_value(value)).map_err(|error| format!("hash json: {error}"))?; | ||
| 192 | |||
| 193 | let mut hasher = Sha256::new(); | ||
| 194 | hasher.update(stable_json.as_bytes()); | ||
| 195 | Ok(format!("{:x}", hasher.finalize())) | ||
| 196 | } | ||
| 197 | |||
| 198 | fn encode_stable_json(value: &Value) -> Result<String, String> { | ||
| 199 | serde_json::to_string(&to_stable_value(value)).map_err(|error| format!("encode stable json: {error}")) | ||
| 200 | } | ||
| 201 | |||
| 202 | fn to_extracted_tool_parameter(value: &Value) -> Option<OpenApiToolParameter> { | ||
| 203 | let record = as_object(value)?; | ||
| 204 | let name = record.get("name")?.as_str()?.trim(); | ||
| 205 | |||
| 206 | if name.is_empty() { | ||
| 207 | return None; | ||
| 208 | } | ||
| 209 | |||
| 210 | let location = record.get("in")?.as_str()?; | ||
| 211 | if !OPEN_API_PARAMETER_LOCATIONS.contains(&location) { | ||
| 212 | return None; | ||
| 213 | } | ||
| 214 | |||
| 215 | let required = location == "path" || record.get("required").and_then(Value::as_bool) == Some(true); | ||
| 216 | |||
| 217 | Some(OpenApiToolParameter { | ||
| 218 | name: name.to_string(), | ||
| 219 | location: location.to_string(), | ||
| 220 | required, | ||
| 221 | }) | ||
| 222 | } | ||
| 223 | |||
| 224 | fn merge_parameters(path_item: &Map<String, Value>, operation: &Map<String, Value>) -> Vec<OpenApiToolParameter> { | ||
| 225 | let mut by_key: HashMap<String, OpenApiToolParameter> = HashMap::new(); | ||
| 226 | |||
| 227 | let mut add_parameters = |candidate: Option<&Value>| { | ||
| 228 | let Some(items) = candidate.and_then(Value::as_array) else { | ||
| 229 | return; | ||
| 230 | }; | ||
| 231 | |||
| 232 | for item in items { | ||
| 233 | if let Some(parameter) = to_extracted_tool_parameter(item) { | ||
| 234 | by_key.insert( | ||
| 235 | format!("{}:{}", parameter.location, parameter.name), | ||
| 236 | parameter, | ||
| 237 | ); | ||
| 238 | } | ||
| 239 | } | ||
| 240 | }; | ||
| 241 | |||
| 242 | add_parameters(path_item.get("parameters")); | ||
| 243 | add_parameters(operation.get("parameters")); | ||
| 244 | |||
| 245 | let mut merged: Vec<OpenApiToolParameter> = by_key.into_values().collect(); | ||
| 246 | merged.sort_by(|left, right| { | ||
| 247 | left.location | ||
| 248 | .cmp(&right.location) | ||
| 249 | .then_with(|| left.name.cmp(&right.name)) | ||
| 250 | }); | ||
| 251 | merged | ||
| 252 | } | ||
| 253 | |||
| 254 | fn extract_request_body(operation: &Map<String, Value>) -> Option<OpenApiToolRequestBody> { | ||
| 255 | let request_body = as_object(operation.get("requestBody")?)?; | ||
| 256 | let content = request_body.get("content").and_then(as_object); | ||
| 257 | |||
| 258 | let mut content_types = content | ||
| 259 | .map(|object| object.keys().cloned().collect::<Vec<String>>()) | ||
| 260 | .unwrap_or_default(); | ||
| 261 | content_types.sort(); | ||
| 262 | |||
| 263 | Some(OpenApiToolRequestBody { | ||
| 264 | required: request_body.get("required").and_then(Value::as_bool) == Some(true), | ||
| 265 | content_types, | ||
| 266 | }) | ||
| 267 | } | ||
| 268 | |||
| 269 | fn build_invocation_metadata( | ||
| 270 | method: &str, | ||
| 271 | path_value: &str, | ||
| 272 | path_item: &Map<String, Value>, | ||
| 273 | operation: &Map<String, Value>, | ||
| 274 | ) -> OpenApiInvocationPayload { | ||
| 275 | OpenApiInvocationPayload { | ||
| 276 | method: method.to_string(), | ||
| 277 | path_template: path_value.to_string(), | ||
| 278 | parameters: merge_parameters(path_item, operation), | ||
| 279 | request_body: extract_request_body(operation), | ||
| 280 | } | ||
| 281 | } | ||
| 282 | |||
| 283 | fn collect_ref_keys(value: &Value, refs: &mut BTreeSet<String>) { | ||
| 284 | match value { | ||
| 285 | Value::Array(items) => { | ||
| 286 | for item in items { | ||
| 287 | collect_ref_keys(item, refs); | ||
| 288 | } | ||
| 289 | } | ||
| 290 | Value::Object(record) => { | ||
| 291 | if let Some(reference) = record.get("$ref").and_then(Value::as_str) | ||
| 292 | && reference.starts_with("#/") | ||
| 293 | { | ||
| 294 | refs.insert(reference.to_string()); | ||
| 295 | } | ||
| 296 | |||
| 297 | for nested in record.values() { | ||
| 298 | collect_ref_keys(nested, refs); | ||
| 299 | } | ||
| 300 | } | ||
| 301 | _ => {} | ||
| 302 | } | ||
| 303 | } | ||
| 304 | |||
| 305 | fn resolve_json_pointer<'a>(root: &'a Value, pointer: &str) -> Option<&'a Value> { | ||
| 306 | if !pointer.starts_with("#/") { | ||
| 307 | return None; | ||
| 308 | } | ||
| 309 | |||
| 310 | let mut current = root; | ||
| 311 | for part in pointer[2..].split('/') { | ||
| 312 | let decoded = part.replace("~1", "/").replace("~0", "~"); | ||
| 313 | let next = current.as_object()?.get(&decoded)?; | ||
| 314 | current = next; | ||
| 315 | } | ||
| 316 | |||
| 317 | Some(current) | ||
| 318 | } | ||
| 319 | |||
| 320 | fn pick_schema_from_content(content: Option<&Value>) -> Option<Value> { | ||
| 321 | let content_record = as_object(content?)?; | ||
| 322 | |||
| 323 | let mut keys: Vec<String> = content_record.keys().cloned().collect(); | ||
| 324 | keys.sort(); | ||
| 325 | |||
| 326 | let mut preferred = Vec::with_capacity(keys.len() + 1); | ||
| 327 | preferred.push("application/json".to_string()); | ||
| 328 | preferred.extend(keys); | ||
| 329 | |||
| 330 | let mut seen = HashSet::new(); | ||
| 331 | for media_type in preferred { | ||
| 332 | if !seen.insert(media_type.clone()) { | ||
| 333 | continue; | ||
| 334 | } | ||
| 335 | |||
| 336 | let Some(media_type_value) = content_record.get(&media_type).and_then(as_object) else { | ||
| 337 | continue; | ||
| 338 | }; | ||
| 339 | |||
| 340 | if let Some(schema) = media_type_value.get("schema") { | ||
| 341 | return Some(schema.clone()); | ||
| 342 | } | ||
| 343 | } | ||
| 344 | |||
| 345 | None | ||
| 346 | } | ||
| 347 | |||
| 348 | fn extract_request_body_schema(operation: &Map<String, Value>) -> Option<Value> { | ||
| 349 | let request_body = as_object(operation.get("requestBody")?)?; | ||
| 350 | pick_schema_from_content(request_body.get("content")) | ||
| 351 | } | ||
| 352 | |||
| 353 | fn extract_response_schema(operation: &Map<String, Value>) -> Option<Value> { | ||
| 354 | let responses = as_object(operation.get("responses")?)?; | ||
| 355 | let mut response_codes: Vec<String> = responses.keys().cloned().collect(); | ||
| 356 | response_codes.sort_by(|left, right| { | ||
| 357 | response_status_rank(left) | ||
| 358 | .cmp(&response_status_rank(right)) | ||
| 359 | .then_with(|| left.cmp(right)) | ||
| 360 | }); | ||
| 361 | |||
| 362 | for response_code in response_codes { | ||
| 363 | let Some(response) = responses.get(&response_code).and_then(as_object) else { | ||
| 364 | continue; | ||
| 365 | }; | ||
| 366 | |||
| 367 | let schema = pick_schema_from_content(response.get("content")); | ||
| 368 | if schema.is_some() { | ||
| 369 | return schema; | ||
| 370 | } | ||
| 371 | } | ||
| 372 | |||
| 373 | None | ||
| 374 | } | ||
| 375 | |||
| 376 | fn collect_parameter_schema_by_key( | ||
| 377 | path_item: &Map<String, Value>, | ||
| 378 | operation: &Map<String, Value>, | ||
| 379 | ) -> HashMap<String, Value> { | ||
| 380 | let mut schemas = HashMap::new(); | ||
| 381 | |||
| 382 | let mut add_parameters = |candidate: Option<&Value>| { | ||
| 383 | let Some(items) = candidate.and_then(Value::as_array) else { | ||
| 384 | return; | ||
| 385 | }; | ||
| 386 | |||
| 387 | for item in items { | ||
| 388 | let Some(item_record) = as_object(item) else { | ||
| 389 | continue; | ||
| 390 | }; | ||
| 391 | |||
| 392 | let Some(parameter) = to_extracted_tool_parameter(item) else { | ||
| 393 | continue; | ||
| 394 | }; | ||
| 395 | |||
| 396 | let Some(schema) = item_record.get("schema") else { | ||
| 397 | continue; | ||
| 398 | }; | ||
| 399 | |||
| 400 | schemas.insert( | ||
| 401 | format!("{}:{}", parameter.location, parameter.name), | ||
| 402 | schema.clone(), | ||
| 403 | ); | ||
| 404 | } | ||
| 405 | }; | ||
| 406 | |||
| 407 | add_parameters(path_item.get("parameters")); | ||
| 408 | add_parameters(operation.get("parameters")); | ||
| 409 | |||
| 410 | schemas | ||
| 411 | } | ||
| 412 | |||
| 413 | fn build_input_schema( | ||
| 414 | path_item: &Map<String, Value>, | ||
| 415 | operation: &Map<String, Value>, | ||
| 416 | invocation: &OpenApiInvocationPayload, | ||
| 417 | ) -> Option<Value> { | ||
| 418 | let parameter_schema_by_key = collect_parameter_schema_by_key(path_item, operation); | ||
| 419 | |||
| 420 | let mut properties = Map::new(); | ||
| 421 | let mut required = BTreeSet::new(); | ||
| 422 | |||
| 423 | for parameter in &invocation.parameters { | ||
| 424 | let key = format!("{}:{}", parameter.location, parameter.name); | ||
| 425 | let schema = parameter_schema_by_key | ||
| 426 | .get(&key) | ||
| 427 | .cloned() | ||
| 428 | .unwrap_or_else(|| json!({ "type": "string" })); | ||
| 429 | |||
| 430 | properties.insert(parameter.name.clone(), schema); | ||
| 431 | |||
| 432 | if parameter.required { | ||
| 433 | required.insert(parameter.name.clone()); | ||
| 434 | } | ||
| 435 | } | ||
| 436 | |||
| 437 | let request_body_schema = extract_request_body_schema(operation); | ||
| 438 | if let Some(schema) = request_body_schema { | ||
| 439 | properties.insert("body".to_string(), schema); | ||
| 440 | |||
| 441 | if invocation | ||
| 442 | .request_body | ||
| 443 | .as_ref() | ||
| 444 | .map(|request_body| request_body.required) | ||
| 445 | == Some(true) | ||
| 446 | { | ||
| 447 | required.insert("body".to_string()); | ||
| 448 | } | ||
| 449 | } | ||
| 450 | |||
| 451 | if properties.is_empty() { | ||
| 452 | return None; | ||
| 453 | } | ||
| 454 | |||
| 455 | let required_list: Vec<String> = required.into_iter().collect(); | ||
| 456 | |||
| 457 | Some(json!({ | ||
| 458 | "type": "object", | ||
| 459 | "properties": properties, | ||
| 460 | "required": required_list, | ||
| 461 | "additionalProperties": false, | ||
| 462 | })) | ||
| 463 | } | ||
| 464 | |||
| 465 | fn build_tool_typing( | ||
| 466 | path_item: &Map<String, Value>, | ||
| 467 | operation: &Map<String, Value>, | ||
| 468 | invocation: &OpenApiInvocationPayload, | ||
| 469 | ) -> Result<Option<DiscoveryTypingPayload>, String> { | ||
| 470 | let input_schema = build_input_schema(path_item, operation, invocation); | ||
| 471 | let output_schema = extract_response_schema(operation); | ||
| 472 | |||
| 473 | if input_schema.is_none() && output_schema.is_none() { | ||
| 474 | return Ok(None); | ||
| 475 | } | ||
| 476 | |||
| 477 | let mut refs = BTreeSet::new(); | ||
| 478 | if let Some(schema) = &input_schema { | ||
| 479 | collect_ref_keys(schema, &mut refs); | ||
| 480 | } | ||
| 481 | |||
| 482 | if let Some(schema) = &output_schema { | ||
| 483 | collect_ref_keys(schema, &mut refs); | ||
| 484 | } | ||
| 485 | |||
| 486 | let ref_hint_keys = if refs.is_empty() { | ||
| 487 | None | ||
| 488 | } else { | ||
| 489 | Some(refs.into_iter().collect()) | ||
| 490 | }; | ||
| 491 | |||
| 492 | Ok(Some(DiscoveryTypingPayload { | ||
| 493 | input_schema_json: input_schema | ||
| 494 | .as_ref() | ||
| 495 | .map(encode_stable_json) | ||
| 496 | .transpose()?, | ||
| 497 | output_schema_json: output_schema | ||
| 498 | .as_ref() | ||
| 499 | .map(encode_stable_json) | ||
| 500 | .transpose()?, | ||
| 501 | ref_hint_keys, | ||
| 502 | })) | ||
| 503 | } | ||
| 504 | |||
| 505 | fn build_ref_hint_table(open_api_spec: &Value, initial_ref_keys: &[String]) -> Result<BTreeMap<String, String>, String> { | ||
| 506 | let mut queue = VecDeque::new(); | ||
| 507 | let mut queued = HashSet::new(); | ||
| 508 | |||
| 509 | for ref_key in initial_ref_keys { | ||
| 510 | if queued.insert(ref_key.clone()) { | ||
| 511 | queue.push_back(ref_key.clone()); | ||
| 512 | } | ||
| 513 | } | ||
| 514 | |||
| 515 | let mut seen = HashSet::new(); | ||
| 516 | let mut table = BTreeMap::new(); | ||
| 517 | |||
| 518 | while let Some(ref_key) = queue.pop_front() { | ||
| 519 | if !seen.insert(ref_key.clone()) { | ||
| 520 | continue; | ||
| 521 | } | ||
| 522 | |||
| 523 | let Some(resolved) = resolve_json_pointer(open_api_spec, &ref_key) else { | ||
| 524 | continue; | ||
| 525 | }; | ||
| 526 | |||
| 527 | table.insert(ref_key.clone(), encode_stable_json(resolved)?); | ||
| 528 | |||
| 529 | let mut nested = BTreeSet::new(); | ||
| 530 | collect_ref_keys(resolved, &mut nested); | ||
| 531 | for nested_ref in nested { | ||
| 532 | if !seen.contains(&nested_ref) { | ||
| 533 | queue.push_back(nested_ref); | ||
| 534 | } | ||
| 535 | } | ||
| 536 | } | ||
| 537 | |||
| 538 | Ok(table) | ||
| 539 | } | ||
| 540 | |||
| 541 | fn normalize_path_for_tool_id(path_value: &str) -> String { | ||
| 542 | let trimmed = path_value.trim(); | ||
| 543 | let no_leading = trimmed.strip_prefix('/').unwrap_or(trimmed); | ||
| 544 | |||
| 545 | let mut normalized = String::new(); | ||
| 546 | let mut last_was_underscore = false; | ||
| 547 | |||
| 548 | for character in no_leading.chars() { | ||
| 549 | if character.is_ascii_alphanumeric() { | ||
| 550 | normalized.push(character); | ||
| 551 | last_was_underscore = false; | ||
| 552 | continue; | ||
| 553 | } | ||
| 554 | |||
| 555 | if !last_was_underscore { | ||
| 556 | normalized.push('_'); | ||
| 557 | last_was_underscore = true; | ||
| 558 | } | ||
| 559 | } | ||
| 560 | |||
| 561 | let without_edges = normalized.trim_matches('_').to_lowercase(); | ||
| 562 | if without_edges.is_empty() { | ||
| 563 | return "root".to_string(); | ||
| 564 | } | ||
| 565 | |||
| 566 | without_edges | ||
| 567 | } | ||
| 568 | |||
| 569 | fn build_tool_id(method: &str, path_value: &str, operation: &Map<String, Value>) -> String { | ||
| 570 | if let Some(operation_id) = as_trimmed_string(operation.get("operationId")) { | ||
| 571 | return operation_id; | ||
| 572 | } | ||
| 573 | |||
| 574 | format!("{}_{}", method, normalize_path_for_tool_id(path_value)) | ||
| 575 | } | ||
| 576 | |||
| 577 | fn build_tool_name(method: &str, path_value: &str, operation: &Map<String, Value>) -> String { | ||
| 578 | if let Some(summary) = as_trimmed_string(operation.get("summary")) { | ||
| 579 | return summary; | ||
| 580 | } | ||
| 581 | |||
| 582 | if let Some(operation_id) = as_trimmed_string(operation.get("operationId")) { | ||
| 583 | return operation_id; | ||
| 584 | } | ||
| 585 | |||
| 586 | format!("{} {}", method.to_uppercase(), path_value) | ||
| 587 | } | ||
| 588 | |||
| 589 | fn build_tool_description(operation: &Map<String, Value>) -> Option<String> { | ||
| 590 | as_trimmed_string(operation.get("description")).or_else(|| as_trimmed_string(operation.get("summary"))) | ||
| 591 | } | ||
| 592 | |||
| 593 | fn ensure_unique_tool_ids(source_name: &str, tools: &[OpenApiExtractedTool]) -> Result<(), String> { | ||
| 594 | let mut seen = HashSet::new(); | ||
| 595 | |||
| 596 | for tool in tools { | ||
| 597 | if !seen.insert(tool.tool_id.clone()) { | ||
| 598 | return Err(format!( | ||
| 599 | "OpenAPI extraction failed for '{source_name}': duplicate toolId {} ({})", | ||
| 600 | tool.tool_id, | ||
| 601 | tool.path | ||
| 602 | )); | ||
| 603 | } | ||
| 604 | } | ||
| 605 | |||
| 606 | Ok(()) | ||
| 607 | } | ||
| 608 | |||
| 609 | fn extract_openapi_manifest(source_name: &str, openapi_spec: &Value) -> Result<OpenApiToolManifest, String> { | ||
| 610 | let spec_record = as_object(openapi_spec) | ||
| 611 | .ok_or_else(|| format!("OpenAPI extraction failed for '{source_name}': spec must be object"))?; | ||
| 612 | |||
| 613 | let Some(paths) = spec_record.get("paths").and_then(as_object) else { | ||
| 614 | return Ok(OpenApiToolManifest { | ||
| 615 | version: 1, | ||
| 616 | source_hash: hash_unknown(openapi_spec)?, | ||
| 617 | tools: Vec::new(), | ||
| 618 | ref_hint_table: None, | ||
| 619 | }); | ||
| 620 | }; | ||
| 621 | |||
| 622 | let mut path_keys: Vec<String> = paths.keys().cloned().collect(); | ||
| 623 | path_keys.sort(); | ||
| 624 | |||
| 625 | let mut tools = Vec::new(); | ||
| 626 | |||
| 627 | for path_value in path_keys { | ||
| 628 | let Some(path_item) = paths.get(&path_value).and_then(as_object) else { | ||
| 629 | continue; | ||
| 630 | }; | ||
| 631 | |||
| 632 | for method in OPEN_API_HTTP_METHODS { | ||
| 633 | let Some(operation) = path_item.get(method).and_then(as_object) else { | ||
| 634 | continue; | ||
| 635 | }; | ||
| 636 | |||
| 637 | let invocation = build_invocation_metadata(method, &path_value, path_item, operation); | ||
| 638 | let typing = build_tool_typing(path_item, operation, &invocation)?; | ||
| 639 | |||
| 640 | let operation_hash_input = json!({ | ||
| 641 | "method": method, | ||
| 642 | "path": path_value, | ||
| 643 | "operation": operation, | ||
| 644 | "invocation": invocation, | ||
| 645 | }); | ||
| 646 | |||
| 647 | tools.push(OpenApiExtractedTool { | ||
| 648 | tool_id: build_tool_id(method, &path_value, operation), | ||
| 649 | name: build_tool_name(method, &path_value, operation), | ||
| 650 | description: build_tool_description(operation), | ||
| 651 | method: method.to_string(), | ||
| 652 | path: path_value.clone(), | ||
| 653 | invocation, | ||
| 654 | operation_hash: hash_unknown(&operation_hash_input)?, | ||
| 655 | typing, | ||
| 656 | }); | ||
| 657 | } | ||
| 658 | } | ||
| 659 | |||
| 660 | tools.sort_by(|left, right| { | ||
| 661 | left.tool_id | ||
| 662 | .to_lowercase() | ||
| 663 | .cmp(&right.tool_id.to_lowercase()) | ||
| 664 | .then_with(|| left.tool_id.cmp(&right.tool_id)) | ||
| 665 | }); | ||
| 666 | ensure_unique_tool_ids(source_name, &tools)?; | ||
| 667 | |||
| 668 | let direct_ref_keys: Vec<String> = tools | ||
| 669 | .iter() | ||
| 670 | .flat_map(|tool| { | ||
| 671 | tool.typing | ||
| 672 | .as_ref() | ||
| 673 | .and_then(|typing| typing.ref_hint_keys.clone()) | ||
| 674 | .unwrap_or_default() | ||
| 675 | }) | ||
| 676 | .collect(); | ||
| 677 | |||
| 678 | let ref_hint_table = build_ref_hint_table(openapi_spec, &direct_ref_keys)?; | ||
| 679 | |||
| 680 | Ok(OpenApiToolManifest { | ||
| 681 | version: 1, | ||
| 682 | source_hash: hash_unknown(openapi_spec)?, | ||
| 683 | tools, | ||
| 684 | ref_hint_table: if ref_hint_table.is_empty() { | ||
| 685 | None | ||
| 686 | } else { | ||
| 687 | Some(ref_hint_table) | ||
| 688 | }, | ||
| 689 | }) | ||
| 690 | } | ||
| @@ -0,0 +1,58 @@ | |||
| 1 | use std::fs; | ||
| 2 | use std::io::{self, Read, Write}; | ||
| 3 | |||
| 4 | use clap::Parser; | ||
| 5 | |||
| 6 | #[derive(Parser, Debug)] | ||
| 7 | #[command(name = "openapi-extractor-rs")] | ||
| 8 | struct Cli { | ||
| 9 | #[arg(long)] | ||
| 10 | source_name: String, | ||
| 11 | #[arg(long)] | ||
| 12 | input: String, | ||
| 13 | #[arg(long)] | ||
| 14 | output: Option<String>, | ||
| 15 | #[arg(long, default_value_t = false)] | ||
| 16 | pretty: bool, | ||
| 17 | } | ||
| 18 | |||
| 19 | fn main() { | ||
| 20 | if let Err(error) = run() { | ||
| 21 | eprintln!("{error}"); | ||
| 22 | std::process::exit(1); | ||
| 23 | } | ||
| 24 | } | ||
| 25 | |||
| 26 | fn run() -> Result<(), String> { | ||
| 27 | let args = Cli::parse(); | ||
| 28 | let source_text = read_input_text(&args.input)?; | ||
| 29 | |||
| 30 | let output_json = | ||
| 31 | openapi_extractor_rs::extract_manifest_json(&args.source_name, &source_text, args.pretty)?; | ||
| 32 | |||
| 33 | if let Some(path) = args.output { | ||
| 34 | fs::write(path, output_json).map_err(|error| format!("write output file: {error}"))?; | ||
| 35 | } else { | ||
| 36 | let mut stdout = io::stdout().lock(); | ||
| 37 | stdout | ||
| 38 | .write_all(output_json.as_bytes()) | ||
| 39 | .map_err(|error| format!("write stdout: {error}"))?; | ||
| 40 | stdout | ||
| 41 | .write_all(b"\n") | ||
| 42 | .map_err(|error| format!("write stdout newline: {error}"))?; | ||
| 43 | } | ||
| 44 | |||
| 45 | Ok(()) | ||
| 46 | } | ||
| 47 | |||
| 48 | fn read_input_text(input: &str) -> Result<String, String> { | ||
| 49 | if input == "-" { | ||
| 50 | let mut buffer = String::new(); | ||
| 51 | io::stdin() | ||
| 52 | .read_to_string(&mut buffer) | ||
| 53 | .map_err(|error| format!("read stdin: {error}"))?; | ||
| 54 | return Ok(buffer); | ||
| 55 | } | ||
| 56 | |||
| 57 | fs::read_to_string(input).map_err(|error| format!("read input file '{input}': {error}")) | ||
| 58 | } | ||