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