1use crate::cache::models::{
2 CachedApertureSecret, CachedCommand, CachedParameter, CachedRequestBody, CachedSpec,
3};
4use crate::config::models::GlobalConfig;
5use crate::config::url_resolver::BaseUrlResolver;
6use crate::error::Error;
7use openapiv3::{OpenAPI, Operation, Parameter as OpenApiParameter, ReferenceOr, SecurityScheme};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11#[derive(Debug, Serialize, Deserialize)]
14pub struct ApiCapabilityManifest {
15 pub api: ApiInfo,
17 pub commands: HashMap<String, Vec<CommandInfo>>,
19 pub security_schemes: HashMap<String, SecuritySchemeInfo>,
21}
22
23#[derive(Debug, Serialize, Deserialize)]
24pub struct ApiInfo {
25 pub name: String,
27 pub version: String,
29 pub description: Option<String>,
31 pub base_url: String,
33}
34
35#[derive(Debug, Serialize, Deserialize)]
36pub struct CommandInfo {
37 pub name: String,
39 pub method: String,
41 pub path: String,
43 pub description: Option<String>,
45 #[serde(skip_serializing_if = "Option::is_none")]
47 pub summary: Option<String>,
48 pub operation_id: String,
50 pub parameters: Vec<ParameterInfo>,
52 pub request_body: Option<RequestBodyInfo>,
54 #[serde(skip_serializing_if = "Vec::is_empty", default)]
56 pub security_requirements: Vec<String>,
57 #[serde(skip_serializing_if = "Vec::is_empty", default)]
59 pub tags: Vec<String>,
60 #[serde(skip_serializing_if = "std::ops::Not::not", default)]
62 pub deprecated: bool,
63 #[serde(skip_serializing_if = "Option::is_none")]
65 pub external_docs_url: Option<String>,
66}
67
68#[derive(Debug, Serialize, Deserialize)]
69pub struct ParameterInfo {
70 pub name: String,
72 pub location: String,
74 pub required: bool,
76 pub param_type: String,
78 pub description: Option<String>,
80 #[serde(skip_serializing_if = "Option::is_none")]
82 pub format: Option<String>,
83 #[serde(skip_serializing_if = "Option::is_none")]
85 pub default_value: Option<String>,
86 #[serde(skip_serializing_if = "Vec::is_empty", default)]
88 pub enum_values: Vec<String>,
89 #[serde(skip_serializing_if = "Option::is_none")]
91 pub example: Option<String>,
92}
93
94#[derive(Debug, Serialize, Deserialize)]
95pub struct RequestBodyInfo {
96 pub required: bool,
98 pub content_type: String,
100 pub description: Option<String>,
102 #[serde(skip_serializing_if = "Option::is_none")]
104 pub example: Option<String>,
105}
106
107#[derive(Debug, Serialize, Deserialize)]
109pub struct SecuritySchemeInfo {
110 #[serde(rename = "type")]
112 pub scheme_type: String,
113 pub description: Option<String>,
115 #[serde(flatten)]
117 pub details: SecuritySchemeDetails,
118 #[serde(rename = "x-aperture-secret", skip_serializing_if = "Option::is_none")]
120 pub aperture_secret: Option<CachedApertureSecret>,
121}
122
123#[derive(Debug, Serialize, Deserialize)]
125#[serde(tag = "scheme", rename_all = "camelCase")]
126pub enum SecuritySchemeDetails {
127 #[serde(rename = "bearer")]
129 HttpBearer {
130 #[serde(skip_serializing_if = "Option::is_none")]
132 bearer_format: Option<String>,
133 },
134 #[serde(rename = "basic")]
136 HttpBasic,
137 #[serde(rename = "apiKey")]
139 ApiKey {
140 #[serde(rename = "in")]
142 location: String,
143 name: String,
145 },
146}
147
148fn to_kebab_case(s: &str) -> String {
150 let mut result = String::new();
151 let mut prev_lowercase = false;
152
153 for (i, ch) in s.chars().enumerate() {
154 if ch.is_uppercase() && i > 0 && prev_lowercase {
155 result.push('-');
156 }
157 result.push(ch.to_ascii_lowercase());
158 prev_lowercase = ch.is_lowercase();
159 }
160
161 result
162}
163
164pub fn generate_capability_manifest_from_openapi(
182 api_name: &str,
183 spec: &OpenAPI,
184 global_config: Option<&GlobalConfig>,
185) -> Result<String, Error> {
186 let base_url = spec.servers.first().map(|s| s.url.clone());
188 let servers: Vec<String> = spec.servers.iter().map(|s| s.url.clone()).collect();
189
190 let temp_cached_spec = CachedSpec {
191 name: api_name.to_string(),
192 version: spec.info.version.clone(),
193 commands: vec![], base_url,
195 servers,
196 security_schemes: HashMap::new(), };
198
199 let resolver = BaseUrlResolver::new(&temp_cached_spec);
201 let resolver = if let Some(config) = global_config {
202 resolver.with_global_config(config)
203 } else {
204 resolver
205 };
206 let resolved_base_url = resolver.resolve(None);
207
208 let mut command_groups: HashMap<String, Vec<CommandInfo>> = HashMap::new();
210
211 for (path, path_item) in &spec.paths.paths {
212 if let ReferenceOr::Item(item) = path_item {
213 for (method, operation) in crate::spec::http_methods_iter(item) {
215 if let Some(op) = operation {
216 let command_info =
217 convert_openapi_operation_to_info(method, path, op, spec.security.as_ref());
218
219 let group_name = op
221 .tags
222 .first()
223 .cloned()
224 .unwrap_or_else(|| "default".to_string());
225
226 command_groups
227 .entry(group_name)
228 .or_default()
229 .push(command_info);
230 }
231 }
232 }
233 }
234
235 let security_schemes = extract_security_schemes_from_openapi(spec);
237
238 let manifest = ApiCapabilityManifest {
240 api: ApiInfo {
241 name: spec.info.title.clone(),
242 version: spec.info.version.clone(),
243 description: spec.info.description.clone(),
244 base_url: resolved_base_url,
245 },
246 commands: command_groups,
247 security_schemes,
248 };
249
250 serde_json::to_string_pretty(&manifest).map_err(Error::Json)
252}
253
254pub fn generate_capability_manifest(
270 spec: &CachedSpec,
271 global_config: Option<&GlobalConfig>,
272) -> Result<String, Error> {
273 let mut command_groups: HashMap<String, Vec<CommandInfo>> = HashMap::new();
274
275 for cached_command in &spec.commands {
277 let group_name = if cached_command.name.is_empty() {
278 "default".to_string()
279 } else {
280 cached_command.name.clone()
281 };
282
283 let command_info = convert_cached_command_to_info(cached_command);
284 command_groups
285 .entry(group_name)
286 .or_default()
287 .push(command_info);
288 }
289
290 let resolver = BaseUrlResolver::new(spec);
292 let resolver = if let Some(config) = global_config {
293 resolver.with_global_config(config)
294 } else {
295 resolver
296 };
297 let base_url = resolver.resolve(None);
298
299 let manifest = ApiCapabilityManifest {
301 api: ApiInfo {
302 name: spec.name.clone(),
303 version: spec.version.clone(),
304 description: None, base_url,
306 },
307 commands: command_groups,
308 security_schemes: extract_security_schemes(spec),
309 };
310
311 serde_json::to_string_pretty(&manifest).map_err(Error::Json)
313}
314
315fn convert_cached_command_to_info(cached_command: &CachedCommand) -> CommandInfo {
317 let command_name = if cached_command.operation_id.is_empty() {
318 cached_command.method.to_lowercase()
319 } else {
320 to_kebab_case(&cached_command.operation_id)
321 };
322
323 let parameters: Vec<ParameterInfo> = cached_command
324 .parameters
325 .iter()
326 .map(convert_cached_parameter_to_info)
327 .collect();
328
329 let request_body = cached_command
330 .request_body
331 .as_ref()
332 .map(convert_cached_request_body_to_info);
333
334 CommandInfo {
335 name: command_name,
336 method: cached_command.method.clone(),
337 path: cached_command.path.clone(),
338 description: cached_command.description.clone(),
339 summary: cached_command.summary.clone(),
340 operation_id: cached_command.operation_id.clone(),
341 parameters,
342 request_body,
343 security_requirements: cached_command.security_requirements.clone(),
344 tags: cached_command.tags.clone(),
345 deprecated: cached_command.deprecated,
346 external_docs_url: cached_command.external_docs_url.clone(),
347 }
348}
349
350fn convert_cached_parameter_to_info(cached_param: &CachedParameter) -> ParameterInfo {
352 ParameterInfo {
353 name: cached_param.name.clone(),
354 location: cached_param.location.clone(),
355 required: cached_param.required,
356 param_type: cached_param
357 .schema_type
358 .clone()
359 .unwrap_or_else(|| "string".to_string()),
360 description: cached_param.description.clone(),
361 format: cached_param.format.clone(),
362 default_value: cached_param.default_value.clone(),
363 enum_values: cached_param.enum_values.clone(),
364 example: cached_param.example.clone(),
365 }
366}
367
368fn convert_cached_request_body_to_info(cached_body: &CachedRequestBody) -> RequestBodyInfo {
370 RequestBodyInfo {
371 required: cached_body.required,
372 content_type: cached_body.content_type.clone(),
373 description: cached_body.description.clone(),
374 example: cached_body.example.clone(),
375 }
376}
377
378fn extract_security_schemes(spec: &CachedSpec) -> HashMap<String, SecuritySchemeInfo> {
380 let mut security_schemes = HashMap::new();
381
382 for (name, scheme) in &spec.security_schemes {
383 let details = match scheme.scheme_type.as_str() {
384 "http" => {
385 scheme.scheme.as_ref().map_or(
386 SecuritySchemeDetails::HttpBearer {
387 bearer_format: None,
388 },
389 |http_scheme| match http_scheme.as_str() {
390 "bearer" => SecuritySchemeDetails::HttpBearer {
391 bearer_format: scheme.bearer_format.clone(),
392 },
393 "basic" => SecuritySchemeDetails::HttpBasic,
394 _ => {
395 SecuritySchemeDetails::HttpBearer {
397 bearer_format: None,
398 }
399 }
400 },
401 )
402 }
403 "apiKey" => SecuritySchemeDetails::ApiKey {
404 location: scheme
405 .location
406 .clone()
407 .unwrap_or_else(|| "header".to_string()),
408 name: scheme
409 .parameter_name
410 .clone()
411 .unwrap_or_else(|| "Authorization".to_string()),
412 },
413 _ => {
414 SecuritySchemeDetails::HttpBearer {
416 bearer_format: None,
417 }
418 }
419 };
420
421 let scheme_info = SecuritySchemeInfo {
422 scheme_type: scheme.scheme_type.clone(),
423 description: scheme.description.clone(),
424 details,
425 aperture_secret: scheme.aperture_secret.clone(),
426 };
427
428 security_schemes.insert(name.clone(), scheme_info);
429 }
430
431 security_schemes
432}
433
434fn convert_openapi_operation_to_info(
436 method: &str,
437 path: &str,
438 operation: &Operation,
439 global_security: Option<&Vec<openapiv3::SecurityRequirement>>,
440) -> CommandInfo {
441 let command_name = operation
442 .operation_id
443 .as_ref()
444 .map_or_else(|| method.to_lowercase(), |op_id| to_kebab_case(op_id));
445
446 let parameters: Vec<ParameterInfo> = operation
448 .parameters
449 .iter()
450 .filter_map(|param_ref| {
451 if let ReferenceOr::Item(param) = param_ref {
452 Some(convert_openapi_parameter_to_info(param))
453 } else {
454 None
455 }
456 })
457 .collect();
458
459 let request_body = operation.request_body.as_ref().and_then(|rb_ref| {
461 if let ReferenceOr::Item(body) = rb_ref {
462 let content_type = if body.content.contains_key("application/json") {
464 "application/json"
465 } else {
466 body.content.keys().next().map(String::as_str)?
467 };
468
469 let media_type = body.content.get(content_type)?;
470 let example = media_type
471 .example
472 .as_ref()
473 .map(|ex| serde_json::to_string(ex).unwrap_or_else(|_| ex.to_string()));
474
475 Some(RequestBodyInfo {
476 required: body.required,
477 content_type: content_type.to_string(),
478 description: body.description.clone(),
479 example,
480 })
481 } else {
482 None
483 }
484 });
485
486 let security_requirements = operation.security.as_ref().map_or_else(
488 || {
489 global_security.map_or(vec![], |reqs| {
490 reqs.iter().flat_map(|req| req.keys().cloned()).collect()
491 })
492 },
493 |op_security| {
494 op_security
495 .iter()
496 .flat_map(|req| req.keys().cloned())
497 .collect()
498 },
499 );
500
501 CommandInfo {
502 name: command_name,
503 method: method.to_uppercase(),
504 path: path.to_string(),
505 description: operation.description.clone(),
506 summary: operation.summary.clone(),
507 operation_id: operation.operation_id.clone().unwrap_or_default(),
508 parameters,
509 request_body,
510 security_requirements,
511 tags: operation.tags.clone(),
512 deprecated: operation.deprecated,
513 external_docs_url: operation
514 .external_docs
515 .as_ref()
516 .map(|docs| docs.url.clone()),
517 }
518}
519
520fn convert_openapi_parameter_to_info(param: &OpenApiParameter) -> ParameterInfo {
522 let (param_data, location_str) = match param {
523 OpenApiParameter::Query { parameter_data, .. } => (parameter_data, "query"),
524 OpenApiParameter::Header { parameter_data, .. } => (parameter_data, "header"),
525 OpenApiParameter::Path { parameter_data, .. } => (parameter_data, "path"),
526 OpenApiParameter::Cookie { parameter_data, .. } => (parameter_data, "cookie"),
527 };
528
529 let (schema_type, format, default_value, enum_values, example) =
531 if let openapiv3::ParameterSchemaOrContent::Schema(schema_ref) = ¶m_data.format {
532 match schema_ref {
533 ReferenceOr::Item(schema) => {
534 let (schema_type, format, enums) = match &schema.schema_kind {
535 openapiv3::SchemaKind::Type(type_val) => match type_val {
536 openapiv3::Type::String(string_type) => {
537 let enum_values: Vec<String> = string_type
538 .enumeration
539 .iter()
540 .filter_map(|v| v.as_ref())
541 .map(|v| {
542 serde_json::to_string(v).unwrap_or_else(|_| v.to_string())
543 })
544 .collect();
545 ("string".to_string(), None, enum_values)
546 }
547 openapiv3::Type::Number(_) => ("number".to_string(), None, vec![]),
548 openapiv3::Type::Integer(_) => ("integer".to_string(), None, vec![]),
549 openapiv3::Type::Boolean(_) => ("boolean".to_string(), None, vec![]),
550 openapiv3::Type::Array(_) => ("array".to_string(), None, vec![]),
551 openapiv3::Type::Object(_) => ("object".to_string(), None, vec![]),
552 },
553 _ => ("string".to_string(), None, vec![]),
554 };
555
556 let default_value = schema
557 .schema_data
558 .default
559 .as_ref()
560 .map(|v| serde_json::to_string(v).unwrap_or_else(|_| v.to_string()));
561
562 (Some(schema_type), format, default_value, enums, None)
563 }
564 ReferenceOr::Reference { .. } => {
565 (Some("string".to_string()), None, None, vec![], None)
566 }
567 }
568 } else {
569 (Some("string".to_string()), None, None, vec![], None)
570 };
571
572 let example = param_data
574 .example
575 .as_ref()
576 .map(|ex| serde_json::to_string(ex).unwrap_or_else(|_| ex.to_string()))
577 .or(example);
578
579 ParameterInfo {
580 name: param_data.name.clone(),
581 location: location_str.to_string(),
582 required: param_data.required,
583 param_type: schema_type.unwrap_or_else(|| "string".to_string()),
584 description: param_data.description.clone(),
585 format,
586 default_value,
587 enum_values,
588 example,
589 }
590}
591
592fn extract_security_schemes_from_openapi(spec: &OpenAPI) -> HashMap<String, SecuritySchemeInfo> {
594 let mut security_schemes = HashMap::new();
595
596 if let Some(components) = &spec.components {
597 for (name, scheme_ref) in &components.security_schemes {
598 if let ReferenceOr::Item(scheme) = scheme_ref {
599 if let Some(scheme_info) = convert_openapi_security_scheme(name, scheme) {
600 security_schemes.insert(name.clone(), scheme_info);
601 }
602 }
603 }
604 }
605
606 security_schemes
607}
608
609fn convert_openapi_security_scheme(
611 _name: &str,
612 scheme: &SecurityScheme,
613) -> Option<SecuritySchemeInfo> {
614 match scheme {
615 SecurityScheme::APIKey {
616 location,
617 name: param_name,
618 description,
619 ..
620 } => {
621 let location_str = match location {
622 openapiv3::APIKeyLocation::Query => "query",
623 openapiv3::APIKeyLocation::Header => "header",
624 openapiv3::APIKeyLocation::Cookie => "cookie",
625 };
626
627 let aperture_secret = extract_aperture_secret_from_extensions(scheme);
628
629 Some(SecuritySchemeInfo {
630 scheme_type: "apiKey".to_string(),
631 description: description.clone(),
632 details: SecuritySchemeDetails::ApiKey {
633 location: location_str.to_string(),
634 name: param_name.clone(),
635 },
636 aperture_secret,
637 })
638 }
639 SecurityScheme::HTTP {
640 scheme: http_scheme,
641 bearer_format,
642 description,
643 ..
644 } => {
645 let details = match http_scheme.as_str() {
646 "bearer" => SecuritySchemeDetails::HttpBearer {
647 bearer_format: bearer_format.clone(),
648 },
649 "basic" => SecuritySchemeDetails::HttpBasic,
650 _ => SecuritySchemeDetails::HttpBearer {
651 bearer_format: None,
652 },
653 };
654
655 let aperture_secret = extract_aperture_secret_from_extensions(scheme);
656
657 Some(SecuritySchemeInfo {
658 scheme_type: "http".to_string(),
659 description: description.clone(),
660 details,
661 aperture_secret,
662 })
663 }
664 SecurityScheme::OAuth2 { .. } | SecurityScheme::OpenIDConnect { .. } => None,
665 }
666}
667
668fn extract_aperture_secret_from_extensions(
670 scheme: &SecurityScheme,
671) -> Option<CachedApertureSecret> {
672 let extensions = match scheme {
673 SecurityScheme::APIKey { extensions, .. } | SecurityScheme::HTTP { extensions, .. } => {
674 extensions
675 }
676 SecurityScheme::OAuth2 { .. } | SecurityScheme::OpenIDConnect { .. } => return None,
677 };
678
679 extensions.get("x-aperture-secret").and_then(|value| {
680 if let Some(obj) = value.as_object() {
681 let source = obj.get("source")?.as_str()?;
682 let name = obj.get("name")?.as_str()?;
683
684 if source == "env" {
685 return Some(CachedApertureSecret {
686 source: source.to_string(),
687 name: name.to_string(),
688 });
689 }
690 }
691 None
692 })
693}
694
695#[cfg(test)]
696mod tests {
697 use super::*;
698 use crate::cache::models::{CachedCommand, CachedParameter, CachedSpec};
699
700 #[test]
701 fn test_to_kebab_case() {
702 assert_eq!(to_kebab_case("getUserById"), "get-user-by-id");
703 assert_eq!(to_kebab_case("createUser"), "create-user");
704 assert_eq!(to_kebab_case("list"), "list");
705 assert_eq!(to_kebab_case("GET"), "get");
706 }
707
708 #[test]
709 fn test_generate_capability_manifest() {
710 use crate::cache::models::{CachedApertureSecret, CachedSecurityScheme};
711
712 let mut security_schemes = HashMap::new();
713 security_schemes.insert(
714 "bearerAuth".to_string(),
715 CachedSecurityScheme {
716 name: "bearerAuth".to_string(),
717 scheme_type: "http".to_string(),
718 scheme: Some("bearer".to_string()),
719 location: Some("header".to_string()),
720 parameter_name: Some("Authorization".to_string()),
721 description: None,
722 bearer_format: None,
723 aperture_secret: Some(CachedApertureSecret {
724 source: "env".to_string(),
725 name: "API_TOKEN".to_string(),
726 }),
727 },
728 );
729
730 let spec = CachedSpec {
731 name: "Test API".to_string(),
732 version: "1.0.0".to_string(),
733 commands: vec![CachedCommand {
734 name: "users".to_string(),
735 description: Some("Get user by ID".to_string()),
736 summary: None,
737 operation_id: "getUserById".to_string(),
738 method: "GET".to_string(),
739 path: "/users/{id}".to_string(),
740 parameters: vec![CachedParameter {
741 name: "id".to_string(),
742 location: "path".to_string(),
743 required: true,
744 description: None,
745 schema: Some("string".to_string()),
746 schema_type: Some("string".to_string()),
747 format: None,
748 default_value: None,
749 enum_values: vec![],
750 example: None,
751 }],
752 request_body: None,
753 responses: vec![],
754 security_requirements: vec!["bearerAuth".to_string()],
755 tags: vec!["users".to_string()],
756 deprecated: false,
757 external_docs_url: None,
758 }],
759 base_url: Some("https://test-api.example.com".to_string()),
760 servers: vec!["https://test-api.example.com".to_string()],
761 security_schemes,
762 };
763
764 let manifest_json = generate_capability_manifest(&spec, None).unwrap();
765 let manifest: ApiCapabilityManifest = serde_json::from_str(&manifest_json).unwrap();
766
767 assert_eq!(manifest.api.name, "Test API");
768 assert_eq!(manifest.api.version, "1.0.0");
769 assert!(manifest.commands.contains_key("users"));
770
771 let users_commands = &manifest.commands["users"];
772 assert_eq!(users_commands.len(), 1);
773 assert_eq!(users_commands[0].name, "get-user-by-id");
774 assert_eq!(users_commands[0].method, "GET");
775 assert_eq!(users_commands[0].parameters.len(), 1);
776 assert_eq!(users_commands[0].parameters[0].name, "id");
777
778 assert!(!manifest.security_schemes.is_empty());
780 assert!(manifest.security_schemes.contains_key("bearerAuth"));
781 let bearer_auth = &manifest.security_schemes["bearerAuth"];
782 assert_eq!(bearer_auth.scheme_type, "http");
783 assert!(matches!(
784 &bearer_auth.details,
785 SecuritySchemeDetails::HttpBearer { .. }
786 ));
787 assert!(bearer_auth.aperture_secret.is_some());
788 let aperture_secret = bearer_auth.aperture_secret.as_ref().unwrap();
789 assert_eq!(aperture_secret.name, "API_TOKEN");
790 assert_eq!(aperture_secret.source, "env");
791 }
792}