1use serde_json::{Value, json};
2use std::collections::HashMap;
3
4use crate::error::OpenApiError;
5use crate::openapi_spec::{OpenApiOperation, OpenApiParameter};
6use crate::server::ToolMetadata;
7
8pub struct ToolGenerator;
10
11impl ToolGenerator {
12 pub fn generate_tool_metadata(
14 operation: &OpenApiOperation,
15 ) -> Result<ToolMetadata, OpenApiError> {
16 let name = operation.operation_id.clone();
17
18 let description = Self::build_description(operation);
20
21 let parameters = Self::generate_parameter_schema(&operation.parameters, &operation.method)?;
23
24 Ok(ToolMetadata {
25 name,
26 description,
27 parameters,
28 method: operation.method.clone(),
29 path: operation.path.clone(),
30 })
31 }
32
33 fn build_description(operation: &OpenApiOperation) -> String {
35 match (&operation.summary, &operation.description) {
36 (Some(summary), Some(desc)) => {
37 format!(
38 "{}\n\n{}\n\nEndpoint: {} {}",
39 summary,
40 desc,
41 operation.method.to_uppercase(),
42 operation.path
43 )
44 }
45 (Some(summary), None) => {
46 format!(
47 "{}\n\nEndpoint: {} {}",
48 summary,
49 operation.method.to_uppercase(),
50 operation.path
51 )
52 }
53 (None, Some(desc)) => {
54 format!(
55 "{}\n\nEndpoint: {} {}",
56 desc,
57 operation.method.to_uppercase(),
58 operation.path
59 )
60 }
61 (None, None) => {
62 format!(
63 "API endpoint: {} {}",
64 operation.method.to_uppercase(),
65 operation.path
66 )
67 }
68 }
69 }
70
71 fn generate_parameter_schema(
73 parameters: &[OpenApiParameter],
74 method: &str,
75 ) -> Result<Value, OpenApiError> {
76 let mut properties = serde_json::Map::new();
77 let mut required = Vec::new();
78
79 let mut path_params = Vec::new();
81 let mut query_params = Vec::new();
82 let mut header_params = Vec::new();
83 let mut cookie_params = Vec::new();
84 let mut body_params = Vec::new();
85
86 for param in parameters {
87 match param.location {
88 crate::openapi_spec::ParameterLocation::Path => path_params.push(param),
89 crate::openapi_spec::ParameterLocation::Query => query_params.push(param),
90 crate::openapi_spec::ParameterLocation::Header => header_params.push(param),
91 crate::openapi_spec::ParameterLocation::Cookie => cookie_params.push(param),
92 crate::openapi_spec::ParameterLocation::FormData => body_params.push(param),
93 }
94 }
95
96 for param in path_params {
98 let param_schema = Self::convert_parameter_schema(param)?;
99 properties.insert(param.name.clone(), param_schema);
100 required.push(param.name.clone());
101 }
102
103 for param in &query_params {
105 let param_schema = Self::convert_parameter_schema(param)?;
106 properties.insert(param.name.clone(), param_schema);
107 if param.required {
108 required.push(param.name.clone());
109 }
110 }
111
112 for param in &header_params {
114 let mut param_schema = Self::convert_parameter_schema(param)?;
115
116 if let Value::Object(ref mut obj) = param_schema {
118 obj.insert("x-location".to_string(), json!("header"));
119 }
120
121 properties.insert(format!("header_{}", param.name), param_schema);
122 if param.required {
123 required.push(format!("header_{}", param.name));
124 }
125 }
126
127 for param in &cookie_params {
129 let mut param_schema = Self::convert_parameter_schema(param)?;
130
131 if let Value::Object(ref mut obj) = param_schema {
133 obj.insert("x-location".to_string(), json!("cookie"));
134 }
135
136 properties.insert(format!("cookie_{}", param.name), param_schema);
137 if param.required {
138 required.push(format!("cookie_{}", param.name));
139 }
140 }
141
142 for param in &body_params {
144 let mut param_schema = Self::convert_parameter_schema(param)?;
145
146 if let Value::Object(ref mut obj) = param_schema {
148 obj.insert("x-location".to_string(), json!("body"));
149 obj.insert("x-content-type".to_string(), json!("application/json"));
150 }
151
152 properties.insert(format!("body_{}", param.name), param_schema);
153 if param.required {
154 required.push(format!("body_{}", param.name));
155 }
156 }
157
158 if body_params.is_empty()
160 && ["post", "put", "patch"].contains(&method.to_lowercase().as_str())
161 {
162 properties.insert(
163 "request_body".to_string(),
164 json!({
165 "type": "object",
166 "description": "Request body data (JSON)",
167 "additionalProperties": true,
168 "x-location": "body",
169 "x-content-type": "application/json"
170 }),
171 );
172 }
173
174 if !query_params.is_empty() || !header_params.is_empty() || !cookie_params.is_empty() {
176 properties.insert(
178 "timeout_seconds".to_string(),
179 json!({
180 "type": "integer",
181 "description": "Request timeout in seconds",
182 "minimum": 1,
183 "maximum": 300,
184 "default": 30
185 }),
186 );
187 }
188
189 Ok(json!({
190 "type": "object",
191 "properties": properties,
192 "required": required,
193 "additionalProperties": false
194 }))
195 }
196
197 fn convert_parameter_schema(param: &OpenApiParameter) -> Result<Value, OpenApiError> {
199 let mut schema = param.schema.clone();
200
201 if !schema.is_object() {
203 schema = json!({
204 "type": param.param_type
205 });
206 }
207
208 let mut result = serde_json::Map::new();
209
210 if let Some(param_type) = schema.get("type") {
212 result.insert("type".to_string(), param_type.clone());
213 } else {
214 result.insert("type".to_string(), json!(param.param_type));
215 }
216
217 if let Some(desc) = ¶m.description {
219 result.insert("description".to_string(), json!(desc));
220 } else {
221 result.insert(
222 "description".to_string(),
223 json!(format!("{} parameter", param.name)),
224 );
225 }
226
227 if param.param_type == "array"
229 || schema.get("type").and_then(|v| v.as_str()) == Some("array")
230 {
231 if let Some(items) = schema.get("items") {
232 result.insert("items".to_string(), items.clone());
233 } else {
234 result.insert("items".to_string(), json!({"type": "string"}));
235 }
236 }
237
238 for key in [
240 "minimum",
241 "maximum",
242 "minLength",
243 "maxLength",
244 "pattern",
245 "enum",
246 "format",
247 ] {
248 if let Some(constraint) = schema.get(key) {
249 result.insert(key.to_string(), constraint.clone());
250 }
251 }
252
253 result.insert(
255 "x-parameter-location".to_string(),
256 json!(param.location.to_string()),
257 );
258 result.insert("x-parameter-required".to_string(), json!(param.required));
259
260 Ok(Value::Object(result))
261 }
262
263 pub fn extract_parameters(
265 tool_metadata: &ToolMetadata,
266 arguments: &Value,
267 ) -> Result<ExtractedParameters, OpenApiError> {
268 let args = arguments
269 .as_object()
270 .ok_or_else(|| OpenApiError::Validation("Arguments must be an object".to_string()))?;
271
272 let mut path_params = HashMap::new();
273 let mut query_params = HashMap::new();
274 let mut header_params = HashMap::new();
275 let mut cookie_params = HashMap::new();
276 let mut body_params = HashMap::new();
277 let mut config = RequestConfig::default();
278
279 if let Some(timeout) = args.get("timeout_seconds").and_then(|v| v.as_u64()) {
281 config.timeout_seconds = timeout as u32;
282 }
283
284 for (key, value) in args {
286 if key == "timeout_seconds" {
287 continue; }
289
290 if key == "request_body" {
292 body_params.insert("request_body".to_string(), value.clone());
293 continue;
294 }
295
296 let location = Self::get_parameter_location(tool_metadata, key)?;
298
299 match location.as_str() {
300 "path" => {
301 path_params.insert(key.clone(), value.clone());
302 }
303 "query" => {
304 query_params.insert(key.clone(), value.clone());
305 }
306 "header" => {
307 let header_name = if key.starts_with("header_") {
309 key.strip_prefix("header_").unwrap_or(key).to_string()
310 } else {
311 key.clone()
312 };
313 header_params.insert(header_name, value.clone());
314 }
315 "cookie" => {
316 let cookie_name = if key.starts_with("cookie_") {
318 key.strip_prefix("cookie_").unwrap_or(key).to_string()
319 } else {
320 key.clone()
321 };
322 cookie_params.insert(cookie_name, value.clone());
323 }
324 "body" => {
325 let body_name = if key.starts_with("body_") {
327 key.strip_prefix("body_").unwrap_or(key).to_string()
328 } else {
329 key.clone()
330 };
331 body_params.insert(body_name, value.clone());
332 }
333 _ => {
334 return Err(OpenApiError::ToolGeneration(format!(
335 "Unknown parameter location for parameter: {key}"
336 )));
337 }
338 }
339 }
340
341 let extracted = ExtractedParameters {
342 path: path_params,
343 query: query_params,
344 headers: header_params,
345 cookies: cookie_params,
346 body: body_params,
347 config,
348 };
349
350 Self::validate_parameters(tool_metadata, &extracted)?;
352
353 Ok(extracted)
354 }
355
356 fn get_parameter_location(
358 tool_metadata: &ToolMetadata,
359 param_name: &str,
360 ) -> Result<String, OpenApiError> {
361 let properties = tool_metadata
362 .parameters
363 .get("properties")
364 .and_then(|p| p.as_object())
365 .ok_or_else(|| {
366 OpenApiError::ToolGeneration("Invalid tool parameters schema".to_string())
367 })?;
368
369 if let Some(param_schema) = properties.get(param_name) {
370 if let Some(location) = param_schema
371 .get("x-parameter-location")
372 .and_then(|v| v.as_str())
373 {
374 return Ok(location.to_string());
375 }
376 }
377
378 if param_name.starts_with("header_") {
380 Ok("header".to_string())
381 } else if param_name.starts_with("cookie_") {
382 Ok("cookie".to_string())
383 } else if param_name.starts_with("body_") {
384 Ok("body".to_string())
385 } else {
386 Ok("query".to_string())
388 }
389 }
390
391 fn validate_parameters(
393 tool_metadata: &ToolMetadata,
394 extracted: &ExtractedParameters,
395 ) -> Result<(), OpenApiError> {
396 let schema = &tool_metadata.parameters;
397
398 let required_params = schema
400 .get("required")
401 .and_then(|r| r.as_array())
402 .map(|arr| {
403 arr.iter()
404 .filter_map(|v| v.as_str())
405 .collect::<std::collections::HashSet<_>>()
406 })
407 .unwrap_or_default();
408
409 let properties = schema
410 .get("properties")
411 .and_then(|p| p.as_object())
412 .ok_or_else(|| {
413 OpenApiError::Validation("Tool schema missing properties".to_string())
414 })?;
415
416 for required_param in &required_params {
418 let param_found = extracted.path.contains_key(*required_param)
419 || extracted.query.contains_key(*required_param)
420 || extracted
421 .headers
422 .contains_key(&required_param.replace("header_", ""))
423 || extracted
424 .cookies
425 .contains_key(&required_param.replace("cookie_", ""))
426 || extracted
427 .body
428 .contains_key(&required_param.replace("body_", ""))
429 || (*required_param == "request_body"
430 && extracted.body.contains_key("request_body"));
431
432 if !param_found {
433 return Err(OpenApiError::InvalidParameter {
434 parameter: required_param.to_string(),
435 reason: "Required parameter is missing".to_string(),
436 });
437 }
438 }
439
440 for (param_name, param_value) in extracted
442 .path
443 .iter()
444 .chain(extracted.query.iter())
445 .chain(extracted.headers.iter())
446 .chain(extracted.cookies.iter())
447 .chain(extracted.body.iter())
448 {
449 if let Some(param_schema) = properties
450 .get(param_name)
451 .or_else(|| properties.get(&format!("header_{param_name}")))
452 .or_else(|| properties.get(&format!("cookie_{param_name}")))
453 .or_else(|| properties.get(&format!("body_{param_name}")))
454 {
455 Self::validate_parameter_value(param_name, param_value, param_schema)?;
456 }
457 }
458
459 Ok(())
460 }
461
462 fn validate_parameter_value(
464 param_name: &str,
465 param_value: &Value,
466 param_schema: &Value,
467 ) -> Result<(), OpenApiError> {
468 let expected_type = param_schema.get("type").and_then(|t| t.as_str());
469
470 match expected_type {
471 Some("string") => {
472 if !param_value.is_string() {
473 return Err(OpenApiError::InvalidParameter {
474 parameter: param_name.to_string(),
475 reason: "Expected string value".to_string(),
476 });
477 }
478
479 if let Some(value_str) = param_value.as_str() {
481 if let Some(min_length) = param_schema.get("minLength").and_then(|v| v.as_u64())
482 {
483 if value_str.len() < min_length as usize {
484 return Err(OpenApiError::InvalidParameter {
485 parameter: param_name.to_string(),
486 reason: format!("String too short, minimum length is {min_length}"),
487 });
488 }
489 }
490
491 if let Some(max_length) = param_schema.get("maxLength").and_then(|v| v.as_u64())
492 {
493 if value_str.len() > max_length as usize {
494 return Err(OpenApiError::InvalidParameter {
495 parameter: param_name.to_string(),
496 reason: format!("String too long, maximum length is {max_length}"),
497 });
498 }
499 }
500
501 if let Some(pattern) = param_schema.get("pattern").and_then(|v| v.as_str()) {
502 if let Ok(regex) = regex::Regex::new(pattern) {
503 if !regex.is_match(value_str) {
504 return Err(OpenApiError::InvalidParameter {
505 parameter: param_name.to_string(),
506 reason: format!("String does not match pattern: {pattern}"),
507 });
508 }
509 }
510 }
511
512 if let Some(enum_values) = param_schema.get("enum").and_then(|v| v.as_array()) {
513 let valid_values: Vec<&str> =
514 enum_values.iter().filter_map(|v| v.as_str()).collect();
515 if !valid_values.contains(&value_str) {
516 return Err(OpenApiError::InvalidParameter {
517 parameter: param_name.to_string(),
518 reason: format!(
519 "Invalid enum value. Valid values: {valid_values:?}"
520 ),
521 });
522 }
523 }
524 }
525 }
526 Some("integer") | Some("number") => {
527 if !param_value.is_number() {
528 return Err(OpenApiError::InvalidParameter {
529 parameter: param_name.to_string(),
530 reason: "Expected numeric value".to_string(),
531 });
532 }
533
534 if let Some(value_num) = param_value.as_f64() {
535 if let Some(minimum) = param_schema.get("minimum").and_then(|v| v.as_f64()) {
536 if value_num < minimum {
537 return Err(OpenApiError::InvalidParameter {
538 parameter: param_name.to_string(),
539 reason: format!("Value {value_num} is below minimum {minimum}"),
540 });
541 }
542 }
543
544 if let Some(maximum) = param_schema.get("maximum").and_then(|v| v.as_f64()) {
545 if value_num > maximum {
546 return Err(OpenApiError::InvalidParameter {
547 parameter: param_name.to_string(),
548 reason: format!("Value {value_num} is above maximum {maximum}"),
549 });
550 }
551 }
552 }
553 }
554 Some("boolean") => {
555 if !param_value.is_boolean() {
556 return Err(OpenApiError::InvalidParameter {
557 parameter: param_name.to_string(),
558 reason: "Expected boolean value".to_string(),
559 });
560 }
561 }
562 Some("array") => {
563 if !param_value.is_array() {
564 return Err(OpenApiError::InvalidParameter {
565 parameter: param_name.to_string(),
566 reason: "Expected array value".to_string(),
567 });
568 }
569 }
570 Some("object") => {
571 if !param_value.is_object() {
572 return Err(OpenApiError::InvalidParameter {
573 parameter: param_name.to_string(),
574 reason: "Expected object value".to_string(),
575 });
576 }
577 }
578 _ => {
579 }
581 }
582
583 Ok(())
584 }
585}
586
587#[derive(Debug, Clone)]
589pub struct ExtractedParameters {
590 pub path: HashMap<String, Value>,
591 pub query: HashMap<String, Value>,
592 pub headers: HashMap<String, Value>,
593 pub cookies: HashMap<String, Value>,
594 pub body: HashMap<String, Value>,
595 pub config: RequestConfig,
596}
597
598#[derive(Debug, Clone)]
600pub struct RequestConfig {
601 pub timeout_seconds: u32,
602 pub content_type: String,
603}
604
605impl Default for RequestConfig {
606 fn default() -> Self {
607 Self {
608 timeout_seconds: 30,
609 content_type: "application/json".to_string(),
610 }
611 }
612}
613
614#[cfg(test)]
615mod tests {
616 use super::*;
617 use crate::openapi_spec::{OpenApiOperation, OpenApiParameter, ParameterLocation};
618
619 #[test]
620 fn test_petstore_get_pet_by_id() {
621 let operation = OpenApiOperation {
622 operation_id: "getPetById".to_string(),
623 summary: Some("Find pet by ID".to_string()),
624 description: Some("Returns a single pet".to_string()),
625 method: "get".to_string(),
626 path: "/pet/{petId}".to_string(),
627 parameters: vec![OpenApiParameter {
628 name: "petId".to_string(),
629 location: ParameterLocation::Path,
630 description: Some("ID of pet to return".to_string()),
631 required: true,
632 param_type: "integer".to_string(),
633 schema: json!({
634 "type": "integer",
635 "format": "int64",
636 "minimum": 1
637 }),
638 }],
639 };
640
641 let metadata = ToolGenerator::generate_tool_metadata(&operation).unwrap();
642 insta::assert_json_snapshot!(metadata);
643 }
644
645 #[test]
646 fn test_petstore_find_pets_by_status() {
647 let operation = OpenApiOperation {
648 operation_id: "findPetsByStatus".to_string(),
649 summary: Some("Finds Pets by status".to_string()),
650 description: Some(
651 "Multiple status values can be provided with comma separated strings".to_string(),
652 ),
653 method: "get".to_string(),
654 path: "/pet/findByStatus".to_string(),
655 parameters: vec![OpenApiParameter {
656 name: "status".to_string(),
657 location: ParameterLocation::Query,
658 description: Some(
659 "Status values that need to be considered for filter".to_string(),
660 ),
661 required: true,
662 param_type: "array".to_string(),
663 schema: json!({
664 "type": "array",
665 "items": {
666 "type": "string",
667 "enum": ["available", "pending", "sold"]
668 }
669 }),
670 }],
671 };
672
673 let metadata = ToolGenerator::generate_tool_metadata(&operation).unwrap();
674 insta::assert_json_snapshot!(metadata);
675 }
676
677 #[test]
678 fn test_petstore_add_pet() {
679 let operation = OpenApiOperation {
680 operation_id: "addPet".to_string(),
681 summary: Some("Add a new pet to the store".to_string()),
682 description: Some("Add a new pet to the store".to_string()),
683 method: "post".to_string(),
684 path: "/pet".to_string(),
685 parameters: vec![],
686 };
687
688 let metadata = ToolGenerator::generate_tool_metadata(&operation).unwrap();
689 insta::assert_json_snapshot!(metadata);
690 }
691
692 #[test]
693 fn test_petstore_update_pet_with_form() {
694 let operation = OpenApiOperation {
695 operation_id: "updatePetWithForm".to_string(),
696 summary: Some("Updates a pet in the store with form data".to_string()),
697 description: None,
698 method: "post".to_string(),
699 path: "/pet/{petId}".to_string(),
700 parameters: vec![
701 OpenApiParameter {
702 name: "petId".to_string(),
703 location: ParameterLocation::Path,
704 description: Some("ID of pet that needs to be updated".to_string()),
705 required: true,
706 param_type: "integer".to_string(),
707 schema: json!({
708 "type": "integer",
709 "format": "int64"
710 }),
711 },
712 OpenApiParameter {
713 name: "name".to_string(),
714 location: ParameterLocation::Query,
715 description: Some("Updated name of the pet".to_string()),
716 required: false,
717 param_type: "string".to_string(),
718 schema: json!({
719 "type": "string"
720 }),
721 },
722 OpenApiParameter {
723 name: "status".to_string(),
724 location: ParameterLocation::Query,
725 description: Some("Updated status of the pet".to_string()),
726 required: false,
727 param_type: "string".to_string(),
728 schema: json!({
729 "type": "string",
730 "enum": ["available", "pending", "sold"]
731 }),
732 },
733 ],
734 };
735
736 let metadata = ToolGenerator::generate_tool_metadata(&operation).unwrap();
737 insta::assert_json_snapshot!(metadata);
738 }
739}