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 cache_format_version: crate::cache::models::CACHE_FORMAT_VERSION,
192 name: api_name.to_string(),
193 version: spec.info.version.clone(),
194 commands: vec![], base_url,
196 servers,
197 security_schemes: HashMap::new(), };
199
200 let resolver = BaseUrlResolver::new(&temp_cached_spec);
202 let resolver = if let Some(config) = global_config {
203 resolver.with_global_config(config)
204 } else {
205 resolver
206 };
207 let resolved_base_url = resolver.resolve(None);
208
209 let mut command_groups: HashMap<String, Vec<CommandInfo>> = HashMap::new();
211
212 for (path, path_item) in &spec.paths.paths {
213 if let ReferenceOr::Item(item) = path_item {
214 for (method, operation) in crate::spec::http_methods_iter(item) {
216 if let Some(op) = operation {
217 let command_info =
218 convert_openapi_operation_to_info(method, path, op, spec.security.as_ref());
219
220 let group_name = op
222 .tags
223 .first()
224 .cloned()
225 .unwrap_or_else(|| "default".to_string());
226
227 command_groups
228 .entry(group_name)
229 .or_default()
230 .push(command_info);
231 }
232 }
233 }
234 }
235
236 let security_schemes = extract_security_schemes_from_openapi(spec);
238
239 let manifest = ApiCapabilityManifest {
241 api: ApiInfo {
242 name: spec.info.title.clone(),
243 version: spec.info.version.clone(),
244 description: spec.info.description.clone(),
245 base_url: resolved_base_url,
246 },
247 commands: command_groups,
248 security_schemes,
249 };
250
251 serde_json::to_string_pretty(&manifest).map_err(Error::Json)
253}
254
255pub fn generate_capability_manifest(
271 spec: &CachedSpec,
272 global_config: Option<&GlobalConfig>,
273) -> Result<String, Error> {
274 let mut command_groups: HashMap<String, Vec<CommandInfo>> = HashMap::new();
275
276 for cached_command in &spec.commands {
278 let group_name = if cached_command.name.is_empty() {
279 "default".to_string()
280 } else {
281 cached_command.name.clone()
282 };
283
284 let command_info = convert_cached_command_to_info(cached_command);
285 command_groups
286 .entry(group_name)
287 .or_default()
288 .push(command_info);
289 }
290
291 let resolver = BaseUrlResolver::new(spec);
293 let resolver = if let Some(config) = global_config {
294 resolver.with_global_config(config)
295 } else {
296 resolver
297 };
298 let base_url = resolver.resolve(None);
299
300 let manifest = ApiCapabilityManifest {
302 api: ApiInfo {
303 name: spec.name.clone(),
304 version: spec.version.clone(),
305 description: None, base_url,
307 },
308 commands: command_groups,
309 security_schemes: extract_security_schemes(spec),
310 };
311
312 serde_json::to_string_pretty(&manifest).map_err(Error::Json)
314}
315
316fn convert_cached_command_to_info(cached_command: &CachedCommand) -> CommandInfo {
318 let command_name = if cached_command.operation_id.is_empty() {
319 cached_command.method.to_lowercase()
320 } else {
321 to_kebab_case(&cached_command.operation_id)
322 };
323
324 let parameters: Vec<ParameterInfo> = cached_command
325 .parameters
326 .iter()
327 .map(convert_cached_parameter_to_info)
328 .collect();
329
330 let request_body = cached_command
331 .request_body
332 .as_ref()
333 .map(convert_cached_request_body_to_info);
334
335 CommandInfo {
336 name: command_name,
337 method: cached_command.method.clone(),
338 path: cached_command.path.clone(),
339 description: cached_command.description.clone(),
340 summary: cached_command.summary.clone(),
341 operation_id: cached_command.operation_id.clone(),
342 parameters,
343 request_body,
344 security_requirements: cached_command.security_requirements.clone(),
345 tags: cached_command.tags.clone(),
346 deprecated: cached_command.deprecated,
347 external_docs_url: cached_command.external_docs_url.clone(),
348 }
349}
350
351fn convert_cached_parameter_to_info(cached_param: &CachedParameter) -> ParameterInfo {
353 ParameterInfo {
354 name: cached_param.name.clone(),
355 location: cached_param.location.clone(),
356 required: cached_param.required,
357 param_type: cached_param
358 .schema_type
359 .clone()
360 .unwrap_or_else(|| "string".to_string()),
361 description: cached_param.description.clone(),
362 format: cached_param.format.clone(),
363 default_value: cached_param.default_value.clone(),
364 enum_values: cached_param.enum_values.clone(),
365 example: cached_param.example.clone(),
366 }
367}
368
369fn convert_cached_request_body_to_info(cached_body: &CachedRequestBody) -> RequestBodyInfo {
371 RequestBodyInfo {
372 required: cached_body.required,
373 content_type: cached_body.content_type.clone(),
374 description: cached_body.description.clone(),
375 example: cached_body.example.clone(),
376 }
377}
378
379fn extract_security_schemes(spec: &CachedSpec) -> HashMap<String, SecuritySchemeInfo> {
381 let mut security_schemes = HashMap::new();
382
383 for (name, scheme) in &spec.security_schemes {
384 let details = match scheme.scheme_type.as_str() {
385 "http" => {
386 scheme.scheme.as_ref().map_or(
387 SecuritySchemeDetails::HttpBearer {
388 bearer_format: None,
389 },
390 |http_scheme| match http_scheme.as_str() {
391 "bearer" => SecuritySchemeDetails::HttpBearer {
392 bearer_format: scheme.bearer_format.clone(),
393 },
394 "basic" => SecuritySchemeDetails::HttpBasic,
395 _ => {
396 SecuritySchemeDetails::HttpBearer {
398 bearer_format: None,
399 }
400 }
401 },
402 )
403 }
404 "apiKey" => SecuritySchemeDetails::ApiKey {
405 location: scheme
406 .location
407 .clone()
408 .unwrap_or_else(|| "header".to_string()),
409 name: scheme
410 .parameter_name
411 .clone()
412 .unwrap_or_else(|| "Authorization".to_string()),
413 },
414 _ => {
415 SecuritySchemeDetails::HttpBearer {
417 bearer_format: None,
418 }
419 }
420 };
421
422 let scheme_info = SecuritySchemeInfo {
423 scheme_type: scheme.scheme_type.clone(),
424 description: scheme.description.clone(),
425 details,
426 aperture_secret: scheme.aperture_secret.clone(),
427 };
428
429 security_schemes.insert(name.clone(), scheme_info);
430 }
431
432 security_schemes
433}
434
435fn convert_openapi_operation_to_info(
437 method: &str,
438 path: &str,
439 operation: &Operation,
440 global_security: Option<&Vec<openapiv3::SecurityRequirement>>,
441) -> CommandInfo {
442 let command_name = operation
443 .operation_id
444 .as_ref()
445 .map_or_else(|| method.to_lowercase(), |op_id| to_kebab_case(op_id));
446
447 let parameters: Vec<ParameterInfo> = operation
449 .parameters
450 .iter()
451 .filter_map(|param_ref| {
452 if let ReferenceOr::Item(param) = param_ref {
453 Some(convert_openapi_parameter_to_info(param))
454 } else {
455 None
456 }
457 })
458 .collect();
459
460 let request_body = operation.request_body.as_ref().and_then(|rb_ref| {
462 if let ReferenceOr::Item(body) = rb_ref {
463 let content_type = if body.content.contains_key("application/json") {
465 "application/json"
466 } else {
467 body.content.keys().next().map(String::as_str)?
468 };
469
470 let media_type = body.content.get(content_type)?;
471 let example = media_type
472 .example
473 .as_ref()
474 .map(|ex| serde_json::to_string(ex).unwrap_or_else(|_| ex.to_string()));
475
476 Some(RequestBodyInfo {
477 required: body.required,
478 content_type: content_type.to_string(),
479 description: body.description.clone(),
480 example,
481 })
482 } else {
483 None
484 }
485 });
486
487 let security_requirements = operation.security.as_ref().map_or_else(
489 || {
490 global_security.map_or(vec![], |reqs| {
491 reqs.iter().flat_map(|req| req.keys().cloned()).collect()
492 })
493 },
494 |op_security| {
495 op_security
496 .iter()
497 .flat_map(|req| req.keys().cloned())
498 .collect()
499 },
500 );
501
502 CommandInfo {
503 name: command_name,
504 method: method.to_uppercase(),
505 path: path.to_string(),
506 description: operation.description.clone(),
507 summary: operation.summary.clone(),
508 operation_id: operation.operation_id.clone().unwrap_or_default(),
509 parameters,
510 request_body,
511 security_requirements,
512 tags: operation.tags.clone(),
513 deprecated: operation.deprecated,
514 external_docs_url: operation
515 .external_docs
516 .as_ref()
517 .map(|docs| docs.url.clone()),
518 }
519}
520
521fn convert_openapi_parameter_to_info(param: &OpenApiParameter) -> ParameterInfo {
523 let (param_data, location_str) = match param {
524 OpenApiParameter::Query { parameter_data, .. } => (parameter_data, "query"),
525 OpenApiParameter::Header { parameter_data, .. } => (parameter_data, "header"),
526 OpenApiParameter::Path { parameter_data, .. } => (parameter_data, "path"),
527 OpenApiParameter::Cookie { parameter_data, .. } => (parameter_data, "cookie"),
528 };
529
530 let (schema_type, format, default_value, enum_values, example) =
532 if let openapiv3::ParameterSchemaOrContent::Schema(schema_ref) = ¶m_data.format {
533 match schema_ref {
534 ReferenceOr::Item(schema) => {
535 let (schema_type, format, enums) = match &schema.schema_kind {
536 openapiv3::SchemaKind::Type(type_val) => match type_val {
537 openapiv3::Type::String(string_type) => {
538 let enum_values: Vec<String> = string_type
539 .enumeration
540 .iter()
541 .filter_map(|v| v.as_ref())
542 .map(|v| {
543 serde_json::to_string(v).unwrap_or_else(|_| v.to_string())
544 })
545 .collect();
546 ("string".to_string(), None, enum_values)
547 }
548 openapiv3::Type::Number(_) => ("number".to_string(), None, vec![]),
549 openapiv3::Type::Integer(_) => ("integer".to_string(), None, vec![]),
550 openapiv3::Type::Boolean(_) => ("boolean".to_string(), None, vec![]),
551 openapiv3::Type::Array(_) => ("array".to_string(), None, vec![]),
552 openapiv3::Type::Object(_) => ("object".to_string(), None, vec![]),
553 },
554 _ => ("string".to_string(), None, vec![]),
555 };
556
557 let default_value = schema
558 .schema_data
559 .default
560 .as_ref()
561 .map(|v| serde_json::to_string(v).unwrap_or_else(|_| v.to_string()));
562
563 (Some(schema_type), format, default_value, enums, None)
564 }
565 ReferenceOr::Reference { .. } => {
566 (Some("string".to_string()), None, None, vec![], None)
567 }
568 }
569 } else {
570 (Some("string".to_string()), None, None, vec![], None)
571 };
572
573 let example = param_data
575 .example
576 .as_ref()
577 .map(|ex| serde_json::to_string(ex).unwrap_or_else(|_| ex.to_string()))
578 .or(example);
579
580 ParameterInfo {
581 name: param_data.name.clone(),
582 location: location_str.to_string(),
583 required: param_data.required,
584 param_type: schema_type.unwrap_or_else(|| "string".to_string()),
585 description: param_data.description.clone(),
586 format,
587 default_value,
588 enum_values,
589 example,
590 }
591}
592
593fn extract_security_schemes_from_openapi(spec: &OpenAPI) -> HashMap<String, SecuritySchemeInfo> {
595 let mut security_schemes = HashMap::new();
596
597 if let Some(components) = &spec.components {
598 for (name, scheme_ref) in &components.security_schemes {
599 if let ReferenceOr::Item(scheme) = scheme_ref {
600 if let Some(scheme_info) = convert_openapi_security_scheme(name, scheme) {
601 security_schemes.insert(name.clone(), scheme_info);
602 }
603 }
604 }
605 }
606
607 security_schemes
608}
609
610fn convert_openapi_security_scheme(
612 _name: &str,
613 scheme: &SecurityScheme,
614) -> Option<SecuritySchemeInfo> {
615 match scheme {
616 SecurityScheme::APIKey {
617 location,
618 name: param_name,
619 description,
620 ..
621 } => {
622 let location_str = match location {
623 openapiv3::APIKeyLocation::Query => "query",
624 openapiv3::APIKeyLocation::Header => "header",
625 openapiv3::APIKeyLocation::Cookie => "cookie",
626 };
627
628 let aperture_secret = extract_aperture_secret_from_extensions(scheme);
629
630 Some(SecuritySchemeInfo {
631 scheme_type: "apiKey".to_string(),
632 description: description.clone(),
633 details: SecuritySchemeDetails::ApiKey {
634 location: location_str.to_string(),
635 name: param_name.clone(),
636 },
637 aperture_secret,
638 })
639 }
640 SecurityScheme::HTTP {
641 scheme: http_scheme,
642 bearer_format,
643 description,
644 ..
645 } => {
646 let details = match http_scheme.as_str() {
647 "bearer" => SecuritySchemeDetails::HttpBearer {
648 bearer_format: bearer_format.clone(),
649 },
650 "basic" => SecuritySchemeDetails::HttpBasic,
651 _ => SecuritySchemeDetails::HttpBearer {
652 bearer_format: None,
653 },
654 };
655
656 let aperture_secret = extract_aperture_secret_from_extensions(scheme);
657
658 Some(SecuritySchemeInfo {
659 scheme_type: "http".to_string(),
660 description: description.clone(),
661 details,
662 aperture_secret,
663 })
664 }
665 SecurityScheme::OAuth2 { .. } | SecurityScheme::OpenIDConnect { .. } => None,
666 }
667}
668
669fn extract_aperture_secret_from_extensions(
671 scheme: &SecurityScheme,
672) -> Option<CachedApertureSecret> {
673 let extensions = match scheme {
674 SecurityScheme::APIKey { extensions, .. } | SecurityScheme::HTTP { extensions, .. } => {
675 extensions
676 }
677 SecurityScheme::OAuth2 { .. } | SecurityScheme::OpenIDConnect { .. } => return None,
678 };
679
680 extensions.get("x-aperture-secret").and_then(|value| {
681 if let Some(obj) = value.as_object() {
682 let source = obj.get("source")?.as_str()?;
683 let name = obj.get("name")?.as_str()?;
684
685 if source == "env" {
686 return Some(CachedApertureSecret {
687 source: source.to_string(),
688 name: name.to_string(),
689 });
690 }
691 }
692 None
693 })
694}
695
696#[cfg(test)]
697mod tests {
698 use super::*;
699 use crate::cache::models::{CachedCommand, CachedParameter, CachedSpec};
700
701 #[test]
702 fn test_to_kebab_case() {
703 assert_eq!(to_kebab_case("getUserById"), "get-user-by-id");
704 assert_eq!(to_kebab_case("createUser"), "create-user");
705 assert_eq!(to_kebab_case("list"), "list");
706 assert_eq!(to_kebab_case("GET"), "get");
707 }
708
709 #[test]
710 fn test_generate_capability_manifest() {
711 use crate::cache::models::{CachedApertureSecret, CachedSecurityScheme};
712
713 let mut security_schemes = HashMap::new();
714 security_schemes.insert(
715 "bearerAuth".to_string(),
716 CachedSecurityScheme {
717 name: "bearerAuth".to_string(),
718 scheme_type: "http".to_string(),
719 scheme: Some("bearer".to_string()),
720 location: Some("header".to_string()),
721 parameter_name: Some("Authorization".to_string()),
722 description: None,
723 bearer_format: None,
724 aperture_secret: Some(CachedApertureSecret {
725 source: "env".to_string(),
726 name: "API_TOKEN".to_string(),
727 }),
728 },
729 );
730
731 let spec = CachedSpec {
732 cache_format_version: crate::cache::models::CACHE_FORMAT_VERSION,
733 name: "Test API".to_string(),
734 version: "1.0.0".to_string(),
735 commands: vec![CachedCommand {
736 name: "users".to_string(),
737 description: Some("Get user by ID".to_string()),
738 summary: None,
739 operation_id: "getUserById".to_string(),
740 method: "GET".to_string(),
741 path: "/users/{id}".to_string(),
742 parameters: vec![CachedParameter {
743 name: "id".to_string(),
744 location: "path".to_string(),
745 required: true,
746 description: None,
747 schema: Some("string".to_string()),
748 schema_type: Some("string".to_string()),
749 format: None,
750 default_value: None,
751 enum_values: vec![],
752 example: None,
753 }],
754 request_body: None,
755 responses: vec![],
756 security_requirements: vec!["bearerAuth".to_string()],
757 tags: vec!["users".to_string()],
758 deprecated: false,
759 external_docs_url: None,
760 }],
761 base_url: Some("https://test-api.example.com".to_string()),
762 servers: vec!["https://test-api.example.com".to_string()],
763 security_schemes,
764 };
765
766 let manifest_json = generate_capability_manifest(&spec, None).unwrap();
767 let manifest: ApiCapabilityManifest = serde_json::from_str(&manifest_json).unwrap();
768
769 assert_eq!(manifest.api.name, "Test API");
770 assert_eq!(manifest.api.version, "1.0.0");
771 assert!(manifest.commands.contains_key("users"));
772
773 let users_commands = &manifest.commands["users"];
774 assert_eq!(users_commands.len(), 1);
775 assert_eq!(users_commands[0].name, "get-user-by-id");
776 assert_eq!(users_commands[0].method, "GET");
777 assert_eq!(users_commands[0].parameters.len(), 1);
778 assert_eq!(users_commands[0].parameters[0].name, "id");
779
780 assert!(!manifest.security_schemes.is_empty());
782 assert!(manifest.security_schemes.contains_key("bearerAuth"));
783 let bearer_auth = &manifest.security_schemes["bearerAuth"];
784 assert_eq!(bearer_auth.scheme_type, "http");
785 assert!(matches!(
786 &bearer_auth.details,
787 SecuritySchemeDetails::HttpBearer { .. }
788 ));
789 assert!(bearer_auth.aperture_secret.is_some());
790 let aperture_secret = bearer_auth.aperture_secret.as_ref().unwrap();
791 assert_eq!(aperture_secret.name, "API_TOKEN");
792 assert_eq!(aperture_secret.source, "env");
793 }
794}