Skip to main content

aperture_cli/spec/
transformer.rs

1use crate::cache::models::{
2    CachedApertureSecret, CachedCommand, CachedParameter, CachedRequestBody, CachedResponse,
3    CachedSecurityScheme, CachedSpec, CommandExample, SkippedEndpoint, CACHE_FORMAT_VERSION,
4};
5use crate::constants;
6use crate::error::Error;
7use crate::utils::to_kebab_case;
8use openapiv3::{OpenAPI, Operation, Parameter, ReferenceOr, RequestBody, SecurityScheme};
9use serde_json;
10use std::collections::HashMap;
11use std::fmt::Write;
12
13/// Type alias for schema type information extracted from a schema kind
14/// Returns: (`schema_type`, `format`, `default_value`, `enum_values`)
15type SchemaTypeInfo = (String, Option<String>, Option<String>, Vec<String>);
16
17/// Type alias for parameter schema information
18/// Returns: (`schema_json`, `schema_type`, `format`, `default_value`, `enum_values`)
19type ParameterSchemaInfo = (
20    Option<String>,
21    Option<String>,
22    Option<String>,
23    Option<String>,
24    Vec<String>,
25);
26
27/// Options for transforming an `OpenAPI` specification
28#[derive(Debug, Clone)]
29pub struct TransformOptions {
30    /// The name of the API
31    pub name: String,
32    /// Endpoints to skip during transformation
33    pub skip_endpoints: Vec<(String, String)>,
34    /// Validation warnings to include in the cached spec
35    pub warnings: Vec<crate::spec::validator::ValidationWarning>,
36}
37
38impl TransformOptions {
39    /// Creates new transform options with the given API name
40    #[must_use]
41    pub fn new(name: impl Into<String>) -> Self {
42        Self {
43            name: name.into(),
44            skip_endpoints: Vec::new(),
45            warnings: Vec::new(),
46        }
47    }
48
49    /// Sets the endpoints to skip
50    #[must_use]
51    pub fn with_skip_endpoints(mut self, endpoints: Vec<(String, String)>) -> Self {
52        self.skip_endpoints = endpoints;
53        self
54    }
55
56    /// Sets the validation warnings
57    #[must_use]
58    pub fn with_warnings(
59        mut self,
60        warnings: Vec<crate::spec::validator::ValidationWarning>,
61    ) -> Self {
62        self.warnings = warnings;
63        self
64    }
65}
66
67/// Transforms `OpenAPI` specifications into Aperture's cached format
68pub struct SpecTransformer;
69
70impl SpecTransformer {
71    /// Creates a new `SpecTransformer` instance
72    #[must_use]
73    pub const fn new() -> Self {
74        Self
75    }
76
77    /// Transforms an `OpenAPI` specification into a cached representation using options
78    ///
79    /// This method converts the full `OpenAPI` spec into an optimized format
80    /// that can be quickly loaded and used for CLI generation.
81    ///
82    /// # Errors
83    ///
84    /// Returns an error if parameter reference resolution fails
85    pub fn transform_with_options(
86        &self,
87        spec: &OpenAPI,
88        options: &TransformOptions,
89    ) -> Result<CachedSpec, Error> {
90        self.transform_with_warnings(
91            &options.name,
92            spec,
93            &options.skip_endpoints,
94            &options.warnings,
95        )
96    }
97
98    /// Transforms an `OpenAPI` specification into a cached representation
99    ///
100    /// This method converts the full `OpenAPI` spec into an optimized format
101    /// that can be quickly loaded and used for CLI generation.
102    ///
103    /// # Errors
104    ///
105    /// Returns an error if parameter reference resolution fails
106    pub fn transform(&self, name: &str, spec: &OpenAPI) -> Result<CachedSpec, Error> {
107        self.transform_with_filter(name, spec, &[])
108    }
109
110    /// Transforms an `OpenAPI` specification into a cached representation with endpoint filtering
111    ///
112    /// This method converts the full `OpenAPI` spec into an optimized format
113    /// that can be quickly loaded and used for CLI generation, filtering out specified endpoints.
114    ///
115    /// # Arguments
116    ///
117    /// * `name` - The name for the cached spec
118    /// * `spec` - The `OpenAPI` specification to transform
119    /// * `skip_endpoints` - List of endpoints to skip (path, method pairs)
120    ///
121    /// # Errors
122    ///
123    /// Returns an error if parameter reference resolution fails
124    pub fn transform_with_filter(
125        &self,
126        name: &str,
127        spec: &OpenAPI,
128        skip_endpoints: &[(String, String)],
129    ) -> Result<CachedSpec, Error> {
130        self.transform_with_warnings(name, spec, skip_endpoints, &[])
131    }
132
133    /// Transforms an `OpenAPI` specification with full warning information
134    ///
135    /// # Arguments
136    ///
137    /// * `name` - The name for the cached spec
138    /// * `spec` - The `OpenAPI` specification to transform
139    /// * `skip_endpoints` - List of endpoints to skip (path, method pairs)
140    /// * `warnings` - Validation warnings to store in the cached spec
141    ///
142    /// # Errors
143    ///
144    /// Returns an error if parameter reference resolution fails
145    pub fn transform_with_warnings(
146        &self,
147        name: &str,
148        spec: &OpenAPI,
149        skip_endpoints: &[(String, String)],
150        warnings: &[crate::spec::validator::ValidationWarning],
151    ) -> Result<CachedSpec, Error> {
152        let mut commands = Vec::new();
153
154        // Extract version from info
155        let version = spec.info.version.clone();
156
157        // Extract server URLs
158        let servers: Vec<String> = spec.servers.iter().map(|s| s.url.clone()).collect();
159        let base_url = servers.first().cloned();
160
161        // Extract server variables from the first server (if any)
162        let server_variables: HashMap<String, crate::cache::models::ServerVariable> = spec
163            .servers
164            .first()
165            .and_then(|server| server.variables.as_ref())
166            .map(|vars| {
167                vars.iter()
168                    .map(|(name, variable)| {
169                        (
170                            name.clone(),
171                            crate::cache::models::ServerVariable {
172                                default: Some(variable.default.clone()),
173                                enum_values: variable.enumeration.clone(),
174                                description: variable.description.clone(),
175                            },
176                        )
177                    })
178                    .collect()
179            })
180            .unwrap_or_default();
181
182        // Extract global security requirements
183        let global_security_requirements: Vec<String> = spec
184            .security
185            .iter()
186            .flat_map(|security_vec| {
187                security_vec
188                    .iter()
189                    .flat_map(|security_req| security_req.keys().cloned())
190            })
191            .collect();
192
193        // Process all paths and operations
194        for (path, path_item) in spec.paths.iter() {
195            Self::process_path_item(
196                spec,
197                path,
198                path_item,
199                skip_endpoints,
200                &global_security_requirements,
201                &mut commands,
202            )?;
203        }
204
205        // Extract security schemes
206        let security_schemes = Self::extract_security_schemes(spec);
207
208        // Convert warnings to skipped endpoints
209        let skipped_endpoints: Vec<SkippedEndpoint> = warnings
210            .iter()
211            .map(|w| SkippedEndpoint {
212                path: w.endpoint.path.clone(),
213                method: w.endpoint.method.clone(),
214                content_type: w.endpoint.content_type.clone(),
215                reason: w.reason.clone(),
216            })
217            .collect();
218
219        Ok(CachedSpec {
220            cache_format_version: CACHE_FORMAT_VERSION,
221            name: name.to_string(),
222            version,
223            commands,
224            base_url,
225            servers,
226            security_schemes,
227            skipped_endpoints,
228            server_variables,
229        })
230    }
231
232    /// Process a single path item and its operations
233    fn process_path_item(
234        spec: &OpenAPI,
235        path: &str,
236        path_item: &ReferenceOr<openapiv3::PathItem>,
237        skip_endpoints: &[(String, String)],
238        global_security_requirements: &[String],
239        commands: &mut Vec<CachedCommand>,
240    ) -> Result<(), Error> {
241        let ReferenceOr::Item(item) = path_item else {
242            return Ok(());
243        };
244
245        // Process each HTTP method
246        for (method, operation) in crate::spec::http_methods_iter(item) {
247            let Some(op) = operation else {
248                continue;
249            };
250
251            if Self::should_skip_endpoint(path, method, skip_endpoints) {
252                continue;
253            }
254
255            let command =
256                Self::transform_operation(spec, method, path, op, global_security_requirements)?;
257            commands.push(command);
258        }
259
260        Ok(())
261    }
262
263    /// Check if an endpoint should be skipped
264    fn should_skip_endpoint(path: &str, method: &str, skip_endpoints: &[(String, String)]) -> bool {
265        skip_endpoints.iter().any(|(skip_path, skip_method)| {
266            skip_path == path && skip_method.eq_ignore_ascii_case(method)
267        })
268    }
269
270    /// Transforms a single operation into a cached command
271    #[allow(clippy::too_many_lines)]
272    fn transform_operation(
273        spec: &OpenAPI,
274        method: &str,
275        path: &str,
276        operation: &Operation,
277        global_security_requirements: &[String],
278    ) -> Result<CachedCommand, Error> {
279        // Extract operation metadata
280        let operation_id = operation
281            .operation_id
282            .clone()
283            .unwrap_or_else(|| format!("{method}_{path}"));
284
285        // Use first tag as command namespace, or "default" if no tags
286        let name = operation
287            .tags
288            .first()
289            .cloned()
290            .unwrap_or_else(|| constants::DEFAULT_GROUP.to_string());
291
292        // Transform parameters
293        let mut parameters = Vec::new();
294        for param_ref in &operation.parameters {
295            match param_ref {
296                ReferenceOr::Item(param) => {
297                    parameters.push(Self::transform_parameter(param));
298                }
299                ReferenceOr::Reference { reference } => {
300                    let param = Self::resolve_parameter_reference(spec, reference)?;
301                    parameters.push(Self::transform_parameter(&param));
302                }
303            }
304        }
305
306        // Transform request body
307        let request_body = operation
308            .request_body
309            .as_ref()
310            .and_then(Self::transform_request_body);
311
312        // Transform responses
313        let responses = operation
314            .responses
315            .responses
316            .iter()
317            .map(|(code, response_ref)| {
318                Self::transform_response(spec, code.to_string(), response_ref)
319            })
320            .collect();
321
322        // Extract security requirements - use operation-level if defined, else global
323        let security_requirements = operation.security.as_ref().map_or_else(
324            || global_security_requirements.to_vec(),
325            |security_reqs| {
326                security_reqs
327                    .iter()
328                    .flat_map(|security_req| security_req.keys().cloned())
329                    .collect()
330            },
331        );
332
333        // Generate examples for this command
334        let examples = Self::generate_command_examples(
335            &name,
336            &operation_id,
337            method,
338            path,
339            &parameters,
340            request_body.as_ref(),
341        );
342
343        Ok(CachedCommand {
344            name,
345            description: operation.description.clone(),
346            summary: operation.summary.clone(),
347            operation_id,
348            method: method.to_uppercase(),
349            path: path.to_string(),
350            parameters,
351            request_body,
352            responses,
353            security_requirements,
354            tags: operation.tags.clone(),
355            deprecated: operation.deprecated,
356            external_docs_url: operation
357                .external_docs
358                .as_ref()
359                .map(|docs| docs.url.clone()),
360            examples,
361            display_group: None,
362            display_name: None,
363            aliases: vec![],
364            hidden: false,
365        })
366    }
367
368    /// Transforms a parameter into cached format
369    #[allow(clippy::too_many_lines)]
370    fn transform_parameter(param: &Parameter) -> CachedParameter {
371        let (param_data, location_str) = match param {
372            Parameter::Query { parameter_data, .. } => {
373                (parameter_data, constants::PARAM_LOCATION_QUERY)
374            }
375            Parameter::Header { parameter_data, .. } => {
376                (parameter_data, constants::PARAM_LOCATION_HEADER)
377            }
378            Parameter::Path { parameter_data, .. } => {
379                (parameter_data, constants::PARAM_LOCATION_PATH)
380            }
381            Parameter::Cookie { parameter_data, .. } => {
382                (parameter_data, constants::PARAM_LOCATION_COOKIE)
383            }
384        };
385
386        // Extract schema information from parameter
387        let (schema_json, schema_type, format, default_value, enum_values) =
388            Self::extract_parameter_schema_info(&param_data.format);
389
390        // Extract example value
391        let example = param_data
392            .example
393            .as_ref()
394            .map(|ex| serde_json::to_string(ex).unwrap_or_else(|_| ex.to_string()));
395
396        CachedParameter {
397            name: param_data.name.clone(),
398            location: location_str.to_string(),
399            required: param_data.required,
400            description: param_data.description.clone(),
401            schema: schema_json,
402            schema_type,
403            format,
404            default_value,
405            enum_values,
406            example,
407        }
408    }
409
410    /// Extracts schema information from parameter schema or content
411    fn extract_parameter_schema_info(
412        format: &openapiv3::ParameterSchemaOrContent,
413    ) -> ParameterSchemaInfo {
414        let openapiv3::ParameterSchemaOrContent::Schema(schema_ref) = format else {
415            // No schema provided, use defaults
416            return (
417                Some(r#"{"type": "string"}"#.to_string()),
418                Some(constants::SCHEMA_TYPE_STRING.to_string()),
419                None,
420                None,
421                vec![],
422            );
423        };
424
425        match schema_ref {
426            ReferenceOr::Item(schema) => {
427                let schema_json = serde_json::to_string(schema).ok();
428
429                // Extract type information
430                let (schema_type, format, default, enums) =
431                    Self::extract_schema_type_info(&schema.schema_kind);
432
433                // Extract default value if present
434                let default_value = schema
435                    .schema_data
436                    .default
437                    .as_ref()
438                    .map(|v| serde_json::to_string(v).unwrap_or_else(|_| v.to_string()));
439
440                (
441                    schema_json,
442                    Some(schema_type),
443                    format,
444                    default_value.or(default),
445                    enums,
446                )
447            }
448            ReferenceOr::Reference { .. } => {
449                // For references, use basic defaults
450                (
451                    Some(r#"{"type": "string"}"#.to_string()),
452                    Some(constants::SCHEMA_TYPE_STRING.to_string()),
453                    None,
454                    None,
455                    vec![],
456                )
457            }
458        }
459    }
460
461    /// Extracts type information from schema kind
462    fn extract_schema_type_info(schema_kind: &openapiv3::SchemaKind) -> SchemaTypeInfo {
463        let openapiv3::SchemaKind::Type(type_val) = schema_kind else {
464            return (
465                constants::SCHEMA_TYPE_STRING.to_string(),
466                None,
467                None,
468                vec![],
469            );
470        };
471
472        match type_val {
473            openapiv3::Type::String(string_type) => Self::extract_string_type_info(string_type),
474            openapiv3::Type::Number(number_type) => Self::extract_number_type_info(number_type),
475            openapiv3::Type::Integer(integer_type) => Self::extract_integer_type_info(integer_type),
476            openapiv3::Type::Boolean(_) => (
477                constants::SCHEMA_TYPE_BOOLEAN.to_string(),
478                None,
479                None,
480                vec![],
481            ),
482            openapiv3::Type::Array(_) => {
483                (constants::SCHEMA_TYPE_ARRAY.to_string(), None, None, vec![])
484            }
485            openapiv3::Type::Object(_) => (
486                constants::SCHEMA_TYPE_OBJECT.to_string(),
487                None,
488                None,
489                vec![],
490            ),
491        }
492    }
493
494    /// Extracts information from a string type schema
495    fn extract_string_type_info(
496        string_type: &openapiv3::StringType,
497    ) -> (String, Option<String>, Option<String>, Vec<String>) {
498        let enum_values: Vec<String> = string_type
499            .enumeration
500            .iter()
501            .filter_map(|v| v.as_ref())
502            .map(|v| serde_json::to_string(v).unwrap_or_else(|_| v.clone()))
503            .collect();
504
505        let format = Self::extract_format_string(&string_type.format);
506
507        (
508            constants::SCHEMA_TYPE_STRING.to_string(),
509            format,
510            None,
511            enum_values,
512        )
513    }
514
515    /// Extracts information from a number type schema
516    fn extract_number_type_info(
517        number_type: &openapiv3::NumberType,
518    ) -> (String, Option<String>, Option<String>, Vec<String>) {
519        let format = match &number_type.format {
520            openapiv3::VariantOrUnknownOrEmpty::Item(fmt) => Some(format!("{fmt:?}")),
521            _ => None,
522        };
523        ("number".to_string(), format, None, vec![])
524    }
525
526    /// Extracts information from an integer type schema
527    fn extract_integer_type_info(
528        integer_type: &openapiv3::IntegerType,
529    ) -> (String, Option<String>, Option<String>, Vec<String>) {
530        let format = match &integer_type.format {
531            openapiv3::VariantOrUnknownOrEmpty::Item(fmt) => Some(format!("{fmt:?}")),
532            _ => None,
533        };
534        (
535            constants::SCHEMA_TYPE_INTEGER.to_string(),
536            format,
537            None,
538            vec![],
539        )
540    }
541
542    /// Extracts format string from a variant or unknown or empty type
543    fn extract_format_string(
544        format: &openapiv3::VariantOrUnknownOrEmpty<openapiv3::StringFormat>,
545    ) -> Option<String> {
546        match format {
547            openapiv3::VariantOrUnknownOrEmpty::Item(fmt) => Some(format!("{fmt:?}")),
548            _ => None,
549        }
550    }
551
552    /// Transforms a response into cached format with schema reference resolution
553    fn transform_response(
554        spec: &OpenAPI,
555        status_code: String,
556        response_ref: &ReferenceOr<openapiv3::Response>,
557    ) -> CachedResponse {
558        let ReferenceOr::Item(response) = response_ref else {
559            return CachedResponse {
560                status_code,
561                description: None,
562                content_type: None,
563                schema: None,
564                example: None,
565            };
566        };
567
568        // Get description
569        let description = if response.description.is_empty() {
570            None
571        } else {
572            Some(response.description.clone())
573        };
574
575        // Prefer application/json content type, otherwise use first available
576        let preferred_content_type = if response.content.contains_key(constants::CONTENT_TYPE_JSON)
577        {
578            Some(constants::CONTENT_TYPE_JSON)
579        } else {
580            response.content.keys().next().map(String::as_str)
581        };
582
583        let (content_type, schema, example) =
584            preferred_content_type.map_or((None, None, None), |ct| {
585                let media_type = response.content.get(ct);
586                let schema = media_type.and_then(|mt| {
587                    mt.schema
588                        .as_ref()
589                        .and_then(|schema_ref| Self::resolve_and_serialize_schema(spec, schema_ref))
590                });
591
592                // Extract example from media type
593                let example = media_type.and_then(|mt| {
594                    mt.example
595                        .as_ref()
596                        .map(|ex| serde_json::to_string(ex).unwrap_or_else(|_| ex.to_string()))
597                });
598
599                (Some(ct.to_string()), schema, example)
600            });
601
602        CachedResponse {
603            status_code,
604            description,
605            content_type,
606            schema,
607            example,
608        }
609    }
610
611    /// Resolves a schema reference (if applicable) and serializes to JSON string
612    fn resolve_and_serialize_schema(
613        spec: &OpenAPI,
614        schema_ref: &ReferenceOr<openapiv3::Schema>,
615    ) -> Option<String> {
616        match schema_ref {
617            ReferenceOr::Item(schema) => serde_json::to_string(schema).ok(),
618            ReferenceOr::Reference { reference } => {
619                // Attempt to resolve the reference
620                crate::spec::resolve_schema_reference(spec, reference)
621                    .ok()
622                    .and_then(|schema| serde_json::to_string(&schema).ok())
623            }
624        }
625    }
626
627    /// Transforms a request body into cached format
628    fn transform_request_body(
629        request_body: &ReferenceOr<RequestBody>,
630    ) -> Option<CachedRequestBody> {
631        match request_body {
632            ReferenceOr::Item(body) => {
633                // Prefer JSON content if available
634                let content_type = if body.content.contains_key(constants::CONTENT_TYPE_JSON) {
635                    constants::CONTENT_TYPE_JSON
636                } else {
637                    body.content.keys().next()?
638                };
639
640                // Extract schema and example from the content
641                let media_type = body.content.get(content_type)?;
642                let schema = media_type
643                    .schema
644                    .as_ref()
645                    .and_then(|schema_ref| match schema_ref {
646                        ReferenceOr::Item(schema) => serde_json::to_string(schema).ok(),
647                        ReferenceOr::Reference { .. } => None,
648                    })
649                    .unwrap_or_else(|| "{}".to_string());
650
651                let example = media_type
652                    .example
653                    .as_ref()
654                    .map(|ex| serde_json::to_string(ex).unwrap_or_else(|_| ex.to_string()));
655
656                Some(CachedRequestBody {
657                    content_type: content_type.to_string(),
658                    schema,
659                    required: body.required,
660                    description: body.description.clone(),
661                    example,
662                })
663            }
664            ReferenceOr::Reference { .. } => None, // Skip references for now
665        }
666    }
667
668    /// Extracts and transforms security schemes from the `OpenAPI` spec
669    fn extract_security_schemes(spec: &OpenAPI) -> HashMap<String, CachedSecurityScheme> {
670        let mut security_schemes = HashMap::new();
671
672        let Some(components) = &spec.components else {
673            return security_schemes;
674        };
675
676        for (name, scheme_ref) in &components.security_schemes {
677            let ReferenceOr::Item(scheme) = scheme_ref else {
678                continue;
679            };
680
681            let Some(cached_scheme) = Self::transform_security_scheme(name, scheme) else {
682                continue;
683            };
684
685            security_schemes.insert(name.clone(), cached_scheme);
686        }
687
688        security_schemes
689    }
690
691    /// Transforms a single security scheme into cached format
692    fn transform_security_scheme(
693        name: &str,
694        scheme: &SecurityScheme,
695    ) -> Option<CachedSecurityScheme> {
696        match scheme {
697            SecurityScheme::APIKey {
698                location,
699                name: param_name,
700                description,
701                ..
702            } => {
703                let aperture_secret = Self::extract_aperture_secret(scheme);
704                let location_str = match location {
705                    openapiv3::APIKeyLocation::Query => constants::PARAM_LOCATION_QUERY,
706                    openapiv3::APIKeyLocation::Header => constants::PARAM_LOCATION_HEADER,
707                    openapiv3::APIKeyLocation::Cookie => constants::PARAM_LOCATION_COOKIE,
708                };
709
710                Some(CachedSecurityScheme {
711                    name: name.to_string(),
712                    scheme_type: constants::AUTH_SCHEME_APIKEY.to_string(),
713                    scheme: None,
714                    location: Some(location_str.to_string()),
715                    parameter_name: Some(param_name.clone()),
716                    description: description.clone(),
717                    bearer_format: None,
718                    aperture_secret,
719                })
720            }
721            SecurityScheme::HTTP {
722                scheme: http_scheme,
723                bearer_format,
724                description,
725                ..
726            } => {
727                let aperture_secret = Self::extract_aperture_secret(scheme);
728                Some(CachedSecurityScheme {
729                    name: name.to_string(),
730                    scheme_type: constants::SECURITY_TYPE_HTTP.to_string(),
731                    scheme: Some(http_scheme.clone()),
732                    location: Some(constants::LOCATION_HEADER.to_string()),
733                    parameter_name: Some(constants::HEADER_AUTHORIZATION.to_string()),
734                    description: description.clone(),
735                    bearer_format: bearer_format.clone(),
736                    aperture_secret,
737                })
738            }
739            // OAuth2 and OpenID Connect should be rejected in validation
740            SecurityScheme::OAuth2 { .. } | SecurityScheme::OpenIDConnect { .. } => None,
741        }
742    }
743
744    /// Extracts x-aperture-secret extension from a security scheme
745    fn extract_aperture_secret(scheme: &SecurityScheme) -> Option<CachedApertureSecret> {
746        // Get extensions from the security scheme
747        let extensions = match scheme {
748            SecurityScheme::APIKey { extensions, .. } | SecurityScheme::HTTP { extensions, .. } => {
749                extensions
750            }
751            SecurityScheme::OAuth2 { .. } | SecurityScheme::OpenIDConnect { .. } => return None,
752        };
753
754        // Parse the x-aperture-secret extension
755        extensions
756            .get(crate::constants::EXT_APERTURE_SECRET)
757            .and_then(|value| {
758                // The extension should be an object with "source" and "name" fields
759                let obj = value.as_object()?;
760                let source = obj.get(crate::constants::EXT_KEY_SOURCE)?.as_str()?;
761                let name = obj.get(crate::constants::EXT_KEY_NAME)?.as_str()?;
762
763                // Currently only "env" source is supported
764                if source != constants::SOURCE_ENV {
765                    return None;
766                }
767
768                Some(CachedApertureSecret {
769                    source: source.to_string(),
770                    name: name.to_string(),
771                })
772            })
773    }
774
775    /// Resolves a parameter reference to its actual parameter definition
776    fn resolve_parameter_reference(spec: &OpenAPI, reference: &str) -> Result<Parameter, Error> {
777        crate::spec::resolve_parameter_reference(spec, reference)
778    }
779
780    /// Generate examples for a command
781    #[allow(clippy::too_many_lines)]
782    fn generate_command_examples(
783        tag: &str,
784        operation_id: &str,
785        method: &str,
786        path: &str,
787        parameters: &[CachedParameter],
788        request_body: Option<&CachedRequestBody>,
789    ) -> Vec<CommandExample> {
790        let mut examples = Vec::new();
791        let operation_kebab = to_kebab_case(operation_id);
792        let tag_kebab = to_kebab_case(tag);
793
794        // Build base command
795        let base_cmd = format!("aperture api myapi {tag_kebab} {operation_kebab}");
796
797        // Example 1: Simple required parameters only
798        let required_params: Vec<&CachedParameter> =
799            parameters.iter().filter(|p| p.required).collect();
800
801        if !required_params.is_empty() {
802            let mut cmd = base_cmd.clone();
803            for param in &required_params {
804                write!(
805                    &mut cmd,
806                    " --{} {}",
807                    param.name,
808                    param.example.as_deref().unwrap_or("<value>")
809                )
810                .expect("writing to String cannot fail");
811            }
812
813            examples.push(CommandExample {
814                description: "Basic usage with required parameters".to_string(),
815                command_line: cmd,
816                explanation: Some(format!("{method} {path}")),
817            });
818        }
819
820        // Example 2: With request body if present
821        if let Some(_body) = request_body {
822            let mut cmd = base_cmd.clone();
823
824            // Add required path/query parameters (only path and query params)
825            let path_query_params = required_params
826                .iter()
827                .filter(|p| p.location == "path" || p.location == "query");
828
829            for param in path_query_params {
830                write!(
831                    &mut cmd,
832                    " --{} {}",
833                    param.name,
834                    param.example.as_deref().unwrap_or("123")
835                )
836                .expect("writing to String cannot fail");
837            }
838
839            // Add body example
840            cmd.push_str(r#" --body '{"name": "example", "value": 42}'"#);
841
842            examples.push(CommandExample {
843                description: "With request body".to_string(),
844                command_line: cmd,
845                explanation: Some("Sends JSON data in the request body".to_string()),
846            });
847        }
848
849        // Example 3: With optional parameters
850        let optional_params: Vec<&CachedParameter> = parameters
851            .iter()
852            .filter(|p| !p.required && p.location == "query")
853            .take(2) // Limit to 2 optional params for brevity
854            .collect();
855
856        if !optional_params.is_empty() && !required_params.is_empty() {
857            let mut cmd = base_cmd.clone();
858
859            // Add required parameters
860            for param in &required_params {
861                write!(
862                    &mut cmd,
863                    " --{} {}",
864                    param.name,
865                    param.example.as_deref().unwrap_or("value")
866                )
867                .expect("writing to String cannot fail");
868            }
869
870            // Add optional parameters
871            for param in &optional_params {
872                write!(
873                    &mut cmd,
874                    " --{} {}",
875                    param.name,
876                    param.example.as_deref().unwrap_or("optional")
877                )
878                .expect("writing to String cannot fail");
879            }
880
881            examples.push(CommandExample {
882                description: "With optional parameters".to_string(),
883                command_line: cmd,
884                explanation: Some(
885                    "Includes optional query parameters for filtering or customization".to_string(),
886                ),
887            });
888        }
889
890        // If no examples were generated, create a simple one
891        if examples.is_empty() {
892            examples.push(CommandExample {
893                description: "Basic usage".to_string(),
894                command_line: base_cmd,
895                explanation: Some(format!("Executes {method} {path}")),
896            });
897        }
898
899        examples
900    }
901}
902
903impl Default for SpecTransformer {
904    fn default() -> Self {
905        Self::new()
906    }
907}
908
909#[cfg(test)]
910#[allow(clippy::default_trait_access)]
911#[allow(clippy::field_reassign_with_default)]
912#[allow(clippy::too_many_lines)]
913mod tests {
914    use super::*;
915    use openapiv3::{
916        Components, Info, OpenAPI, Operation, Parameter, ParameterData, ParameterSchemaOrContent,
917        PathItem, ReferenceOr, Responses, Schema, SchemaData, SchemaKind, Type,
918    };
919
920    fn create_test_spec() -> OpenAPI {
921        OpenAPI {
922            openapi: "3.0.0".to_string(),
923            info: Info {
924                title: "Test API".to_string(),
925                version: "1.0.0".to_string(),
926                ..Default::default()
927            },
928            servers: vec![openapiv3::Server {
929                url: "https://api.example.com".to_string(),
930                ..Default::default()
931            }],
932            paths: Default::default(),
933            ..Default::default()
934        }
935    }
936
937    #[test]
938    fn test_transform_basic_spec() {
939        let transformer = SpecTransformer::new();
940        let spec = create_test_spec();
941        let cached = transformer
942            .transform("test", &spec)
943            .expect("Transform should succeed");
944
945        assert_eq!(cached.name, "test");
946        assert_eq!(cached.version, "1.0.0");
947        assert_eq!(cached.base_url, Some("https://api.example.com".to_string()));
948        assert_eq!(cached.servers.len(), 1);
949        assert!(cached.commands.is_empty());
950        assert!(cached.server_variables.is_empty());
951    }
952
953    #[test]
954    fn test_transform_spec_with_server_variables() {
955        let mut variables = indexmap::IndexMap::new();
956        variables.insert(
957            "region".to_string(),
958            openapiv3::ServerVariable {
959                default: "us".to_string(),
960                description: Some("The regional instance".to_string()),
961                enumeration: vec!["us".to_string(), "eu".to_string()],
962                extensions: indexmap::IndexMap::new(),
963            },
964        );
965
966        let spec = OpenAPI {
967            openapi: "3.0.0".to_string(),
968            info: Info {
969                title: "Test API".to_string(),
970                version: "1.0.0".to_string(),
971                ..Default::default()
972            },
973            servers: vec![openapiv3::Server {
974                url: "https://{region}.api.example.com".to_string(),
975                description: Some("Regional server".to_string()),
976                variables: Some(variables),
977                extensions: indexmap::IndexMap::new(),
978            }],
979            ..Default::default()
980        };
981
982        let transformer = SpecTransformer::new();
983        let cached = transformer.transform("test", &spec).unwrap();
984
985        // Test server variable extraction
986        assert_eq!(cached.server_variables.len(), 1);
987        assert!(cached.server_variables.contains_key("region"));
988
989        let region_var = &cached.server_variables["region"];
990        assert_eq!(region_var.default, Some("us".to_string()));
991        assert_eq!(
992            region_var.description,
993            Some("The regional instance".to_string())
994        );
995        assert_eq!(
996            region_var.enum_values,
997            vec!["us".to_string(), "eu".to_string()]
998        );
999
1000        // Basic spec info
1001        assert_eq!(cached.name, "test");
1002        assert_eq!(
1003            cached.base_url,
1004            Some("https://{region}.api.example.com".to_string())
1005        );
1006    }
1007
1008    #[test]
1009    fn test_transform_spec_with_empty_default_server_variable() {
1010        let mut variables = indexmap::IndexMap::new();
1011        variables.insert(
1012            "prefix".to_string(),
1013            openapiv3::ServerVariable {
1014                default: String::new(), // Empty string default should be preserved
1015                description: Some("Optional prefix".to_string()),
1016                enumeration: vec![],
1017                extensions: indexmap::IndexMap::new(),
1018            },
1019        );
1020
1021        let spec = OpenAPI {
1022            openapi: "3.0.0".to_string(),
1023            info: Info {
1024                title: "Test API".to_string(),
1025                version: "1.0.0".to_string(),
1026                ..Default::default()
1027            },
1028            servers: vec![openapiv3::Server {
1029                url: "https://{prefix}api.example.com".to_string(),
1030                description: Some("Server with empty default".to_string()),
1031                variables: Some(variables),
1032                extensions: indexmap::IndexMap::new(),
1033            }],
1034            ..Default::default()
1035        };
1036
1037        let transformer = SpecTransformer::new();
1038        let cached = transformer.transform("test", &spec).unwrap();
1039
1040        // Verify empty string default is preserved
1041        assert!(cached.server_variables.contains_key("prefix"));
1042        let prefix_var = &cached.server_variables["prefix"];
1043        assert_eq!(prefix_var.default, Some(String::new()));
1044        assert_eq!(prefix_var.description, Some("Optional prefix".to_string()));
1045    }
1046
1047    #[test]
1048    fn test_transform_with_operations() {
1049        let transformer = SpecTransformer::new();
1050        let mut spec = create_test_spec();
1051
1052        let mut path_item = PathItem::default();
1053        path_item.get = Some(Operation {
1054            operation_id: Some("getUsers".to_string()),
1055            tags: vec!["users".to_string()],
1056            description: Some("Get all users".to_string()),
1057            responses: Responses::default(),
1058            ..Default::default()
1059        });
1060
1061        spec.paths
1062            .paths
1063            .insert("/users".to_string(), ReferenceOr::Item(path_item));
1064
1065        let cached = transformer
1066            .transform("test", &spec)
1067            .expect("Transform should succeed");
1068
1069        assert_eq!(cached.commands.len(), 1);
1070        let command = &cached.commands[0];
1071        assert_eq!(command.name, "users");
1072        assert_eq!(command.operation_id, "getUsers");
1073        assert_eq!(command.method, constants::HTTP_METHOD_GET);
1074        assert_eq!(command.path, "/users");
1075        assert_eq!(command.description, Some("Get all users".to_string()));
1076    }
1077
1078    #[test]
1079    fn test_transform_with_parameter_reference() {
1080        let transformer = SpecTransformer::new();
1081        let mut spec = create_test_spec();
1082
1083        // Add a parameter to components
1084        let mut components = Components::default();
1085        let user_id_param = Parameter::Path {
1086            parameter_data: ParameterData {
1087                name: "userId".to_string(),
1088                description: Some("Unique identifier of the user".to_string()),
1089                required: true,
1090                deprecated: Some(false),
1091                format: ParameterSchemaOrContent::Schema(ReferenceOr::Item(Schema {
1092                    schema_data: SchemaData::default(),
1093                    schema_kind: SchemaKind::Type(Type::String(Default::default())),
1094                })),
1095                example: None,
1096                examples: Default::default(),
1097                explode: None,
1098                extensions: Default::default(),
1099            },
1100            style: Default::default(),
1101        };
1102        components
1103            .parameters
1104            .insert("userId".to_string(), ReferenceOr::Item(user_id_param));
1105        spec.components = Some(components);
1106
1107        // Create operation with parameter reference
1108        let mut path_item = PathItem::default();
1109        path_item.get = Some(Operation {
1110            operation_id: Some("getUserById".to_string()),
1111            tags: vec!["users".to_string()],
1112            parameters: vec![ReferenceOr::Reference {
1113                reference: "#/components/parameters/userId".to_string(),
1114            }],
1115            responses: Responses::default(),
1116            ..Default::default()
1117        });
1118
1119        spec.paths
1120            .paths
1121            .insert("/users/{userId}".to_string(), ReferenceOr::Item(path_item));
1122
1123        let cached = transformer
1124            .transform("test", &spec)
1125            .expect("Transform should succeed with parameter reference");
1126
1127        // Verify the parameter was resolved
1128        assert_eq!(cached.commands.len(), 1);
1129        let command = &cached.commands[0];
1130        assert_eq!(command.parameters.len(), 1);
1131        let param = &command.parameters[0];
1132        assert_eq!(param.name, "userId");
1133        assert_eq!(param.location, constants::PARAM_LOCATION_PATH);
1134        assert!(param.required);
1135        assert_eq!(
1136            param.description,
1137            Some("Unique identifier of the user".to_string())
1138        );
1139    }
1140
1141    #[test]
1142    fn test_transform_with_invalid_parameter_reference() {
1143        let transformer = SpecTransformer::new();
1144        let mut spec = create_test_spec();
1145
1146        // Create operation with invalid parameter reference
1147        let mut path_item = PathItem::default();
1148        path_item.get = Some(Operation {
1149            parameters: vec![ReferenceOr::Reference {
1150                reference: "#/invalid/reference/format".to_string(),
1151            }],
1152            responses: Responses::default(),
1153            ..Default::default()
1154        });
1155
1156        spec.paths
1157            .paths
1158            .insert("/users".to_string(), ReferenceOr::Item(path_item));
1159
1160        let result = transformer.transform("test", &spec);
1161        assert!(result.is_err());
1162        match result.unwrap_err() {
1163            crate::error::Error::Internal {
1164                kind: crate::error::ErrorKind::Validation,
1165                message: msg,
1166                ..
1167            } => {
1168                assert!(msg.contains("Invalid parameter reference format"));
1169            }
1170            _ => panic!("Expected Validation error"),
1171        }
1172    }
1173
1174    #[test]
1175    fn test_transform_with_missing_parameter_reference() {
1176        let transformer = SpecTransformer::new();
1177        let mut spec = create_test_spec();
1178
1179        // Add empty components
1180        spec.components = Some(Components::default());
1181
1182        // Create operation with reference to non-existent parameter
1183        let mut path_item = PathItem::default();
1184        path_item.get = Some(Operation {
1185            parameters: vec![ReferenceOr::Reference {
1186                reference: "#/components/parameters/nonExistent".to_string(),
1187            }],
1188            responses: Responses::default(),
1189            ..Default::default()
1190        });
1191
1192        spec.paths
1193            .paths
1194            .insert("/users".to_string(), ReferenceOr::Item(path_item));
1195
1196        let result = transformer.transform("test", &spec);
1197        assert!(result.is_err());
1198        match result.unwrap_err() {
1199            crate::error::Error::Internal {
1200                kind: crate::error::ErrorKind::Validation,
1201                message: msg,
1202                ..
1203            } => {
1204                assert!(msg.contains("Parameter 'nonExistent' not found in components"));
1205            }
1206            _ => panic!("Expected Validation error"),
1207        }
1208    }
1209
1210    #[test]
1211    fn test_transform_with_nested_parameter_reference() {
1212        let transformer = SpecTransformer::new();
1213        let mut spec = create_test_spec();
1214
1215        let mut components = Components::default();
1216
1217        // Add a parameter that references another parameter
1218        components.parameters.insert(
1219            "userIdRef".to_string(),
1220            ReferenceOr::Reference {
1221                reference: "#/components/parameters/userId".to_string(),
1222            },
1223        );
1224
1225        // Add the actual parameter
1226        let user_id_param = Parameter::Path {
1227            parameter_data: ParameterData {
1228                name: "userId".to_string(),
1229                description: Some("User ID parameter".to_string()),
1230                required: true,
1231                deprecated: Some(false),
1232                format: ParameterSchemaOrContent::Schema(ReferenceOr::Item(Schema {
1233                    schema_data: SchemaData::default(),
1234                    schema_kind: SchemaKind::Type(Type::String(Default::default())),
1235                })),
1236                example: None,
1237                examples: Default::default(),
1238                explode: None,
1239                extensions: Default::default(),
1240            },
1241            style: Default::default(),
1242        };
1243        components
1244            .parameters
1245            .insert("userId".to_string(), ReferenceOr::Item(user_id_param));
1246        spec.components = Some(components);
1247
1248        // Create operation with nested parameter reference
1249        let mut path_item = PathItem::default();
1250        path_item.get = Some(Operation {
1251            parameters: vec![ReferenceOr::Reference {
1252                reference: "#/components/parameters/userIdRef".to_string(),
1253            }],
1254            responses: Responses::default(),
1255            ..Default::default()
1256        });
1257
1258        spec.paths
1259            .paths
1260            .insert("/users/{userId}".to_string(), ReferenceOr::Item(path_item));
1261
1262        let cached = transformer
1263            .transform("test", &spec)
1264            .expect("Transform should succeed with nested parameter reference");
1265
1266        // Verify the nested reference was resolved
1267        assert_eq!(cached.commands.len(), 1);
1268        let command = &cached.commands[0];
1269        assert_eq!(command.parameters.len(), 1);
1270        let param = &command.parameters[0];
1271        assert_eq!(param.name, "userId");
1272        assert_eq!(param.description, Some("User ID parameter".to_string()));
1273    }
1274
1275    #[test]
1276    fn test_transform_with_circular_parameter_reference() {
1277        let transformer = SpecTransformer::new();
1278        let mut spec = create_test_spec();
1279
1280        let mut components = Components::default();
1281
1282        // Create direct circular reference: paramA -> paramA
1283        components.parameters.insert(
1284            "paramA".to_string(),
1285            ReferenceOr::Reference {
1286                reference: "#/components/parameters/paramA".to_string(),
1287            },
1288        );
1289
1290        spec.components = Some(components);
1291
1292        // Create operation with circular parameter reference
1293        let mut path_item = PathItem::default();
1294        path_item.get = Some(Operation {
1295            parameters: vec![ReferenceOr::Reference {
1296                reference: "#/components/parameters/paramA".to_string(),
1297            }],
1298            responses: Responses::default(),
1299            ..Default::default()
1300        });
1301
1302        spec.paths
1303            .paths
1304            .insert("/test".to_string(), ReferenceOr::Item(path_item));
1305
1306        let result = transformer.transform("test", &spec);
1307        assert!(result.is_err());
1308        match result.unwrap_err() {
1309            crate::error::Error::Internal {
1310                kind: crate::error::ErrorKind::Validation,
1311                message: msg,
1312                ..
1313            } => {
1314                assert!(
1315                    msg.contains("Circular reference detected"),
1316                    "Error message should mention circular reference: {msg}"
1317                );
1318            }
1319            _ => panic!("Expected Validation error for circular reference"),
1320        }
1321    }
1322
1323    #[test]
1324    fn test_transform_with_indirect_circular_reference() {
1325        let transformer = SpecTransformer::new();
1326        let mut spec = create_test_spec();
1327
1328        let mut components = Components::default();
1329
1330        // Create indirect circular reference: paramA -> paramB -> paramA
1331        components.parameters.insert(
1332            "paramA".to_string(),
1333            ReferenceOr::Reference {
1334                reference: "#/components/parameters/paramB".to_string(),
1335            },
1336        );
1337
1338        components.parameters.insert(
1339            "paramB".to_string(),
1340            ReferenceOr::Reference {
1341                reference: "#/components/parameters/paramA".to_string(),
1342            },
1343        );
1344
1345        spec.components = Some(components);
1346
1347        // Create operation with circular parameter reference
1348        let mut path_item = PathItem::default();
1349        path_item.get = Some(Operation {
1350            parameters: vec![ReferenceOr::Reference {
1351                reference: "#/components/parameters/paramA".to_string(),
1352            }],
1353            responses: Responses::default(),
1354            ..Default::default()
1355        });
1356
1357        spec.paths
1358            .paths
1359            .insert("/test".to_string(), ReferenceOr::Item(path_item));
1360
1361        let result = transformer.transform("test", &spec);
1362        assert!(result.is_err());
1363        match result.unwrap_err() {
1364            crate::error::Error::Internal {
1365                kind: crate::error::ErrorKind::Validation,
1366                message: msg,
1367                ..
1368            } => {
1369                assert!(
1370                    msg.contains("Circular reference detected") || msg.contains("reference cycle"),
1371                    "Error message should mention circular reference: {msg}"
1372                );
1373            }
1374            _ => panic!("Expected Validation error for circular reference"),
1375        }
1376    }
1377
1378    #[test]
1379    fn test_transform_with_complex_circular_reference() {
1380        let transformer = SpecTransformer::new();
1381        let mut spec = create_test_spec();
1382
1383        let mut components = Components::default();
1384
1385        // Create complex circular reference: paramA -> paramB -> paramC -> paramA
1386        components.parameters.insert(
1387            "paramA".to_string(),
1388            ReferenceOr::Reference {
1389                reference: "#/components/parameters/paramB".to_string(),
1390            },
1391        );
1392
1393        components.parameters.insert(
1394            "paramB".to_string(),
1395            ReferenceOr::Reference {
1396                reference: "#/components/parameters/paramC".to_string(),
1397            },
1398        );
1399
1400        components.parameters.insert(
1401            "paramC".to_string(),
1402            ReferenceOr::Reference {
1403                reference: "#/components/parameters/paramA".to_string(),
1404            },
1405        );
1406
1407        spec.components = Some(components);
1408
1409        // Create operation with circular parameter reference
1410        let mut path_item = PathItem::default();
1411        path_item.get = Some(Operation {
1412            parameters: vec![ReferenceOr::Reference {
1413                reference: "#/components/parameters/paramA".to_string(),
1414            }],
1415            responses: Responses::default(),
1416            ..Default::default()
1417        });
1418
1419        spec.paths
1420            .paths
1421            .insert("/test".to_string(), ReferenceOr::Item(path_item));
1422
1423        let result = transformer.transform("test", &spec);
1424        assert!(result.is_err());
1425        match result.unwrap_err() {
1426            crate::error::Error::Internal {
1427                kind: crate::error::ErrorKind::Validation,
1428                message: msg,
1429                ..
1430            } => {
1431                assert!(
1432                    msg.contains("Circular reference detected") || msg.contains("reference cycle"),
1433                    "Error message should mention circular reference: {msg}"
1434                );
1435            }
1436            _ => panic!("Expected Validation error for circular reference"),
1437        }
1438    }
1439
1440    #[test]
1441    fn test_transform_with_depth_limit() {
1442        let transformer = SpecTransformer::new();
1443        let mut spec = create_test_spec();
1444
1445        let mut components = Components::default();
1446
1447        // Create a chain of references that exceeds MAX_REFERENCE_DEPTH
1448        for i in 0..12 {
1449            let param_name = format!("param{i}");
1450            let next_param = format!("param{}", i + 1);
1451
1452            if i < 11 {
1453                // Reference to next parameter
1454                components.parameters.insert(
1455                    param_name,
1456                    ReferenceOr::Reference {
1457                        reference: format!("#/components/parameters/{next_param}"),
1458                    },
1459                );
1460            } else {
1461                // Last parameter is actual parameter definition
1462                let actual_param = Parameter::Path {
1463                    parameter_data: ParameterData {
1464                        name: "deepParam".to_string(),
1465                        description: Some("Very deeply nested parameter".to_string()),
1466                        required: true,
1467                        deprecated: Some(false),
1468                        format: ParameterSchemaOrContent::Schema(ReferenceOr::Item(Schema {
1469                            schema_data: SchemaData::default(),
1470                            schema_kind: SchemaKind::Type(Type::String(Default::default())),
1471                        })),
1472                        example: None,
1473                        examples: Default::default(),
1474                        explode: None,
1475                        extensions: Default::default(),
1476                    },
1477                    style: Default::default(),
1478                };
1479                components
1480                    .parameters
1481                    .insert(param_name, ReferenceOr::Item(actual_param));
1482            }
1483        }
1484
1485        spec.components = Some(components);
1486
1487        // Create operation with deeply nested parameter reference
1488        let mut path_item = PathItem::default();
1489        path_item.get = Some(Operation {
1490            parameters: vec![ReferenceOr::Reference {
1491                reference: "#/components/parameters/param0".to_string(),
1492            }],
1493            responses: Responses::default(),
1494            ..Default::default()
1495        });
1496
1497        spec.paths
1498            .paths
1499            .insert("/test".to_string(), ReferenceOr::Item(path_item));
1500
1501        let result = transformer.transform("test", &spec);
1502        assert!(result.is_err());
1503        match result.unwrap_err() {
1504            crate::error::Error::Internal {
1505                kind: crate::error::ErrorKind::Validation,
1506                message: msg,
1507                ..
1508            } => {
1509                assert!(
1510                    msg.contains("Maximum reference depth") && msg.contains("10"),
1511                    "Error message should mention depth limit: {msg}"
1512                );
1513            }
1514            _ => panic!("Expected Validation error for depth limit"),
1515        }
1516    }
1517}