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