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