| @@ -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 | } |