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        })
362    }
363
364    /// Transforms a parameter into cached format
365    #[allow(clippy::too_many_lines)]
366    fn transform_parameter(param: &Parameter) -> CachedParameter {
367        let (param_data, location_str) = match param {
368            Parameter::Query { parameter_data, .. } => {
369                (parameter_data, constants::PARAM_LOCATION_QUERY)
370            }
371            Parameter::Header { parameter_data, .. } => {
372                (parameter_data, constants::PARAM_LOCATION_HEADER)
373            }
374            Parameter::Path { parameter_data, .. } => {
375                (parameter_data, constants::PARAM_LOCATION_PATH)
376            }
377            Parameter::Cookie { parameter_data, .. } => {
378                (parameter_data, constants::PARAM_LOCATION_COOKIE)
379            }
380        };
381
382        // Extract schema information from parameter
383        let (schema_json, schema_type, format, default_value, enum_values) =
384            Self::extract_parameter_schema_info(&param_data.format);
385
386        // Extract example value
387        let example = param_data
388            .example
389            .as_ref()
390            .map(|ex| serde_json::to_string(ex).unwrap_or_else(|_| ex.to_string()));
391
392        CachedParameter {
393            name: param_data.name.clone(),
394            location: location_str.to_string(),
395            required: param_data.required,
396            description: param_data.description.clone(),
397            schema: schema_json,
398            schema_type,
399            format,
400            default_value,
401            enum_values,
402            example,
403        }
404    }
405
406    /// Extracts schema information from parameter schema or content
407    fn extract_parameter_schema_info(
408        format: &openapiv3::ParameterSchemaOrContent,
409    ) -> ParameterSchemaInfo {
410        let openapiv3::ParameterSchemaOrContent::Schema(schema_ref) = format else {
411            // No schema provided, use defaults
412            return (
413                Some(r#"{"type": "string"}"#.to_string()),
414                Some(constants::SCHEMA_TYPE_STRING.to_string()),
415                None,
416                None,
417                vec![],
418            );
419        };
420
421        match schema_ref {
422            ReferenceOr::Item(schema) => {
423                let schema_json = serde_json::to_string(schema).ok();
424
425                // Extract type information
426                let (schema_type, format, default, enums) =
427                    Self::extract_schema_type_info(&schema.schema_kind);
428
429                // Extract default value if present
430                let default_value = schema
431                    .schema_data
432                    .default
433                    .as_ref()
434                    .map(|v| serde_json::to_string(v).unwrap_or_else(|_| v.to_string()));
435
436                (
437                    schema_json,
438                    Some(schema_type),
439                    format,
440                    default_value.or(default),
441                    enums,
442                )
443            }
444            ReferenceOr::Reference { .. } => {
445                // For references, use basic defaults
446                (
447                    Some(r#"{"type": "string"}"#.to_string()),
448                    Some(constants::SCHEMA_TYPE_STRING.to_string()),
449                    None,
450                    None,
451                    vec![],
452                )
453            }
454        }
455    }
456
457    /// Extracts type information from schema kind
458    fn extract_schema_type_info(schema_kind: &openapiv3::SchemaKind) -> SchemaTypeInfo {
459        let openapiv3::SchemaKind::Type(type_val) = schema_kind else {
460            return (
461                constants::SCHEMA_TYPE_STRING.to_string(),
462                None,
463                None,
464                vec![],
465            );
466        };
467
468        match type_val {
469            openapiv3::Type::String(string_type) => Self::extract_string_type_info(string_type),
470            openapiv3::Type::Number(number_type) => Self::extract_number_type_info(number_type),
471            openapiv3::Type::Integer(integer_type) => Self::extract_integer_type_info(integer_type),
472            openapiv3::Type::Boolean(_) => (
473                constants::SCHEMA_TYPE_BOOLEAN.to_string(),
474                None,
475                None,
476                vec![],
477            ),
478            openapiv3::Type::Array(_) => {
479                (constants::SCHEMA_TYPE_ARRAY.to_string(), None, None, vec![])
480            }
481            openapiv3::Type::Object(_) => (
482                constants::SCHEMA_TYPE_OBJECT.to_string(),
483                None,
484                None,
485                vec![],
486            ),
487        }
488    }
489
490    /// Extracts information from a string type schema
491    fn extract_string_type_info(
492        string_type: &openapiv3::StringType,
493    ) -> (String, Option<String>, Option<String>, Vec<String>) {
494        let enum_values: Vec<String> = string_type
495            .enumeration
496            .iter()
497            .filter_map(|v| v.as_ref())
498            .map(|v| serde_json::to_string(v).unwrap_or_else(|_| v.clone()))
499            .collect();
500
501        let format = Self::extract_format_string(&string_type.format);
502
503        (
504            constants::SCHEMA_TYPE_STRING.to_string(),
505            format,
506            None,
507            enum_values,
508        )
509    }
510
511    /// Extracts information from a number type schema
512    fn extract_number_type_info(
513        number_type: &openapiv3::NumberType,
514    ) -> (String, Option<String>, Option<String>, Vec<String>) {
515        let format = match &number_type.format {
516            openapiv3::VariantOrUnknownOrEmpty::Item(fmt) => Some(format!("{fmt:?}")),
517            _ => None,
518        };
519        ("number".to_string(), format, None, vec![])
520    }
521
522    /// Extracts information from an integer type schema
523    fn extract_integer_type_info(
524        integer_type: &openapiv3::IntegerType,
525    ) -> (String, Option<String>, Option<String>, Vec<String>) {
526        let format = match &integer_type.format {
527            openapiv3::VariantOrUnknownOrEmpty::Item(fmt) => Some(format!("{fmt:?}")),
528            _ => None,
529        };
530        (
531            constants::SCHEMA_TYPE_INTEGER.to_string(),
532            format,
533            None,
534            vec![],
535        )
536    }
537
538    /// Extracts format string from a variant or unknown or empty type
539    fn extract_format_string(
540        format: &openapiv3::VariantOrUnknownOrEmpty<openapiv3::StringFormat>,
541    ) -> Option<String> {
542        match format {
543            openapiv3::VariantOrUnknownOrEmpty::Item(fmt) => Some(format!("{fmt:?}")),
544            _ => None,
545        }
546    }
547
548    /// Transforms a response into cached format with schema reference resolution
549    fn transform_response(
550        spec: &OpenAPI,
551        status_code: String,
552        response_ref: &ReferenceOr<openapiv3::Response>,
553    ) -> CachedResponse {
554        let ReferenceOr::Item(response) = response_ref else {
555            return CachedResponse {
556                status_code,
557                description: None,
558                content_type: None,
559                schema: None,
560                example: None,
561            };
562        };
563
564        // Get description
565        let description = if response.description.is_empty() {
566            None
567        } else {
568            Some(response.description.clone())
569        };
570
571        // Prefer application/json content type, otherwise use first available
572        let preferred_content_type = if response.content.contains_key(constants::CONTENT_TYPE_JSON)
573        {
574            Some(constants::CONTENT_TYPE_JSON)
575        } else {
576            response.content.keys().next().map(String::as_str)
577        };
578
579        let (content_type, schema, example) =
580            preferred_content_type.map_or((None, None, None), |ct| {
581                let media_type = response.content.get(ct);
582                let schema = media_type.and_then(|mt| {
583                    mt.schema
584                        .as_ref()
585                        .and_then(|schema_ref| Self::resolve_and_serialize_schema(spec, schema_ref))
586                });
587
588                // Extract example from media type
589                let example = media_type.and_then(|mt| {
590                    mt.example
591                        .as_ref()
592                        .map(|ex| serde_json::to_string(ex).unwrap_or_else(|_| ex.to_string()))
593                });
594
595                (Some(ct.to_string()), schema, example)
596            });
597
598        CachedResponse {
599            status_code,
600            description,
601            content_type,
602            schema,
603            example,
604        }
605    }
606
607    /// Resolves a schema reference (if applicable) and serializes to JSON string
608    fn resolve_and_serialize_schema(
609        spec: &OpenAPI,
610        schema_ref: &ReferenceOr<openapiv3::Schema>,
611    ) -> Option<String> {
612        match schema_ref {
613            ReferenceOr::Item(schema) => serde_json::to_string(schema).ok(),
614            ReferenceOr::Reference { reference } => {
615                // Attempt to resolve the reference
616                crate::spec::resolve_schema_reference(spec, reference)
617                    .ok()
618                    .and_then(|schema| serde_json::to_string(&schema).ok())
619            }
620        }
621    }
622
623    /// Transforms a request body into cached format
624    fn transform_request_body(
625        request_body: &ReferenceOr<RequestBody>,
626    ) -> Option<CachedRequestBody> {
627        match request_body {
628            ReferenceOr::Item(body) => {
629                // Prefer JSON content if available
630                let content_type = if body.content.contains_key(constants::CONTENT_TYPE_JSON) {
631                    constants::CONTENT_TYPE_JSON
632                } else {
633                    body.content.keys().next()?
634                };
635
636                // Extract schema and example from the content
637                let media_type = body.content.get(content_type)?;
638                let schema = media_type
639                    .schema
640                    .as_ref()
641                    .and_then(|schema_ref| match schema_ref {
642                        ReferenceOr::Item(schema) => serde_json::to_string(schema).ok(),
643                        ReferenceOr::Reference { .. } => None,
644                    })
645                    .unwrap_or_else(|| "{}".to_string());
646
647                let example = media_type
648                    .example
649                    .as_ref()
650                    .map(|ex| serde_json::to_string(ex).unwrap_or_else(|_| ex.to_string()));
651
652                Some(CachedRequestBody {
653                    content_type: content_type.to_string(),
654                    schema,
655                    required: body.required,
656                    description: body.description.clone(),
657                    example,
658                })
659            }
660            ReferenceOr::Reference { .. } => None, // Skip references for now
661        }
662    }
663
664    /// Extracts and transforms security schemes from the `OpenAPI` spec
665    fn extract_security_schemes(spec: &OpenAPI) -> HashMap<String, CachedSecurityScheme> {
666        let mut security_schemes = HashMap::new();
667
668        let Some(components) = &spec.components else {
669            return security_schemes;
670        };
671
672        for (name, scheme_ref) in &components.security_schemes {
673            let ReferenceOr::Item(scheme) = scheme_ref else {
674                continue;
675            };
676
677            let Some(cached_scheme) = Self::transform_security_scheme(name, scheme) else {
678                continue;
679            };
680
681            security_schemes.insert(name.clone(), cached_scheme);
682        }
683
684        security_schemes
685    }
686
687    /// Transforms a single security scheme into cached format
688    fn transform_security_scheme(
689        name: &str,
690        scheme: &SecurityScheme,
691    ) -> Option<CachedSecurityScheme> {
692        match scheme {
693            SecurityScheme::APIKey {
694                location,
695                name: param_name,
696                description,
697                ..
698            } => {
699                let aperture_secret = Self::extract_aperture_secret(scheme);
700                let location_str = match location {
701                    openapiv3::APIKeyLocation::Query => constants::PARAM_LOCATION_QUERY,
702                    openapiv3::APIKeyLocation::Header => constants::PARAM_LOCATION_HEADER,
703                    openapiv3::APIKeyLocation::Cookie => constants::PARAM_LOCATION_COOKIE,
704                };
705
706                Some(CachedSecurityScheme {
707                    name: name.to_string(),
708                    scheme_type: constants::AUTH_SCHEME_APIKEY.to_string(),
709                    scheme: None,
710                    location: Some(location_str.to_string()),
711                    parameter_name: Some(param_name.clone()),
712                    description: description.clone(),
713                    bearer_format: None,
714                    aperture_secret,
715                })
716            }
717            SecurityScheme::HTTP {
718                scheme: http_scheme,
719                bearer_format,
720                description,
721                ..
722            } => {
723                let aperture_secret = Self::extract_aperture_secret(scheme);
724                Some(CachedSecurityScheme {
725                    name: name.to_string(),
726                    scheme_type: constants::SECURITY_TYPE_HTTP.to_string(),
727                    scheme: Some(http_scheme.clone()),
728                    location: Some(constants::LOCATION_HEADER.to_string()),
729                    parameter_name: Some(constants::HEADER_AUTHORIZATION.to_string()),
730                    description: description.clone(),
731                    bearer_format: bearer_format.clone(),
732                    aperture_secret,
733                })
734            }
735            // OAuth2 and OpenID Connect should be rejected in validation
736            SecurityScheme::OAuth2 { .. } | SecurityScheme::OpenIDConnect { .. } => None,
737        }
738    }
739
740    /// Extracts x-aperture-secret extension from a security scheme
741    fn extract_aperture_secret(scheme: &SecurityScheme) -> Option<CachedApertureSecret> {
742        // Get extensions from the security scheme
743        let extensions = match scheme {
744            SecurityScheme::APIKey { extensions, .. } | SecurityScheme::HTTP { extensions, .. } => {
745                extensions
746            }
747            SecurityScheme::OAuth2 { .. } | SecurityScheme::OpenIDConnect { .. } => return None,
748        };
749
750        // Parse the x-aperture-secret extension
751        extensions
752            .get(crate::constants::EXT_APERTURE_SECRET)
753            .and_then(|value| {
754                // The extension should be an object with "source" and "name" fields
755                let obj = value.as_object()?;
756                let source = obj.get(crate::constants::EXT_KEY_SOURCE)?.as_str()?;
757                let name = obj.get(crate::constants::EXT_KEY_NAME)?.as_str()?;
758
759                // Currently only "env" source is supported
760                if source != constants::SOURCE_ENV {
761                    return None;
762                }
763
764                Some(CachedApertureSecret {
765                    source: source.to_string(),
766                    name: name.to_string(),
767                })
768            })
769    }
770
771    /// Resolves a parameter reference to its actual parameter definition
772    fn resolve_parameter_reference(spec: &OpenAPI, reference: &str) -> Result<Parameter, Error> {
773        crate::spec::resolve_parameter_reference(spec, reference)
774    }
775
776    /// Generate examples for a command
777    #[allow(clippy::too_many_lines)]
778    fn generate_command_examples(
779        tag: &str,
780        operation_id: &str,
781        method: &str,
782        path: &str,
783        parameters: &[CachedParameter],
784        request_body: Option<&CachedRequestBody>,
785    ) -> Vec<CommandExample> {
786        let mut examples = Vec::new();
787        let operation_kebab = to_kebab_case(operation_id);
788        let tag_kebab = to_kebab_case(tag);
789
790        // Build base command
791        let base_cmd = format!("aperture api myapi {tag_kebab} {operation_kebab}");
792
793        // Example 1: Simple required parameters only
794        let required_params: Vec<&CachedParameter> =
795            parameters.iter().filter(|p| p.required).collect();
796
797        if !required_params.is_empty() {
798            let mut cmd = base_cmd.clone();
799            for param in &required_params {
800                write!(
801                    &mut cmd,
802                    " --{} {}",
803                    param.name,
804                    param.example.as_deref().unwrap_or("<value>")
805                )
806                .expect("writing to String cannot fail");
807            }
808
809            examples.push(CommandExample {
810                description: "Basic usage with required parameters".to_string(),
811                command_line: cmd,
812                explanation: Some(format!("{method} {path}")),
813            });
814        }
815
816        // Example 2: With request body if present
817        if let Some(_body) = request_body {
818            let mut cmd = base_cmd.clone();
819
820            // Add required path/query parameters (only path and query params)
821            let path_query_params = required_params
822                .iter()
823                .filter(|p| p.location == "path" || p.location == "query");
824
825            for param in path_query_params {
826                write!(
827                    &mut cmd,
828                    " --{} {}",
829                    param.name,
830                    param.example.as_deref().unwrap_or("123")
831                )
832                .expect("writing to String cannot fail");
833            }
834
835            // Add body example
836            cmd.push_str(r#" --body '{"name": "example", "value": 42}'"#);
837
838            examples.push(CommandExample {
839                description: "With request body".to_string(),
840                command_line: cmd,
841                explanation: Some("Sends JSON data in the request body".to_string()),
842            });
843        }
844
845        // Example 3: With optional parameters
846        let optional_params: Vec<&CachedParameter> = parameters
847            .iter()
848            .filter(|p| !p.required && p.location == "query")
849            .take(2) // Limit to 2 optional params for brevity
850            .collect();
851
852        if !optional_params.is_empty() && !required_params.is_empty() {
853            let mut cmd = base_cmd.clone();
854
855            // Add required parameters
856            for param in &required_params {
857                write!(
858                    &mut cmd,
859                    " --{} {}",
860                    param.name,
861                    param.example.as_deref().unwrap_or("value")
862                )
863                .expect("writing to String cannot fail");
864            }
865
866            // Add optional parameters
867            for param in &optional_params {
868                write!(
869                    &mut cmd,
870                    " --{} {}",
871                    param.name,
872                    param.example.as_deref().unwrap_or("optional")
873                )
874                .expect("writing to String cannot fail");
875            }
876
877            examples.push(CommandExample {
878                description: "With optional parameters".to_string(),
879                command_line: cmd,
880                explanation: Some(
881                    "Includes optional query parameters for filtering or customization".to_string(),
882                ),
883            });
884        }
885
886        // If no examples were generated, create a simple one
887        if examples.is_empty() {
888            examples.push(CommandExample {
889                description: "Basic usage".to_string(),
890                command_line: base_cmd,
891                explanation: Some(format!("Executes {method} {path}")),
892            });
893        }
894
895        examples
896    }
897}
898
899impl Default for SpecTransformer {
900    fn default() -> Self {
901        Self::new()
902    }
903}
904
905#[cfg(test)]
906#[allow(clippy::default_trait_access)]
907#[allow(clippy::field_reassign_with_default)]
908#[allow(clippy::too_many_lines)]
909mod tests {
910    use super::*;
911    use openapiv3::{
912        Components, Info, OpenAPI, Operation, Parameter, ParameterData, ParameterSchemaOrContent,
913        PathItem, ReferenceOr, Responses, Schema, SchemaData, SchemaKind, Type,
914    };
915
916    fn create_test_spec() -> OpenAPI {
917        OpenAPI {
918            openapi: "3.0.0".to_string(),
919            info: Info {
920                title: "Test API".to_string(),
921                version: "1.0.0".to_string(),
922                ..Default::default()
923            },
924            servers: vec![openapiv3::Server {
925                url: "https://api.example.com".to_string(),
926                ..Default::default()
927            }],
928            paths: Default::default(),
929            ..Default::default()
930        }
931    }
932
933    #[test]
934    fn test_transform_basic_spec() {
935        let transformer = SpecTransformer::new();
936        let spec = create_test_spec();
937        let cached = transformer
938            .transform("test", &spec)
939            .expect("Transform should succeed");
940
941        assert_eq!(cached.name, "test");
942        assert_eq!(cached.version, "1.0.0");
943        assert_eq!(cached.base_url, Some("https://api.example.com".to_string()));
944        assert_eq!(cached.servers.len(), 1);
945        assert!(cached.commands.is_empty());
946        assert!(cached.server_variables.is_empty());
947    }
948
949    #[test]
950    fn test_transform_spec_with_server_variables() {
951        let mut variables = indexmap::IndexMap::new();
952        variables.insert(
953            "region".to_string(),
954            openapiv3::ServerVariable {
955                default: "us".to_string(),
956                description: Some("The regional instance".to_string()),
957                enumeration: vec!["us".to_string(), "eu".to_string()],
958                extensions: indexmap::IndexMap::new(),
959            },
960        );
961
962        let spec = OpenAPI {
963            openapi: "3.0.0".to_string(),
964            info: Info {
965                title: "Test API".to_string(),
966                version: "1.0.0".to_string(),
967                ..Default::default()
968            },
969            servers: vec![openapiv3::Server {
970                url: "https://{region}.api.example.com".to_string(),
971                description: Some("Regional server".to_string()),
972                variables: Some(variables),
973                extensions: indexmap::IndexMap::new(),
974            }],
975            ..Default::default()
976        };
977
978        let transformer = SpecTransformer::new();
979        let cached = transformer.transform("test", &spec).unwrap();
980
981        // Test server variable extraction
982        assert_eq!(cached.server_variables.len(), 1);
983        assert!(cached.server_variables.contains_key("region"));
984
985        let region_var = &cached.server_variables["region"];
986        assert_eq!(region_var.default, Some("us".to_string()));
987        assert_eq!(
988            region_var.description,
989            Some("The regional instance".to_string())
990        );
991        assert_eq!(
992            region_var.enum_values,
993            vec!["us".to_string(), "eu".to_string()]
994        );
995
996        // Basic spec info
997        assert_eq!(cached.name, "test");
998        assert_eq!(
999            cached.base_url,
1000            Some("https://{region}.api.example.com".to_string())
1001        );
1002    }
1003
1004    #[test]
1005    fn test_transform_spec_with_empty_default_server_variable() {
1006        let mut variables = indexmap::IndexMap::new();
1007        variables.insert(
1008            "prefix".to_string(),
1009            openapiv3::ServerVariable {
1010                default: String::new(), // Empty string default should be preserved
1011                description: Some("Optional prefix".to_string()),
1012                enumeration: vec![],
1013                extensions: indexmap::IndexMap::new(),
1014            },
1015        );
1016
1017        let spec = OpenAPI {
1018            openapi: "3.0.0".to_string(),
1019            info: Info {
1020                title: "Test API".to_string(),
1021                version: "1.0.0".to_string(),
1022                ..Default::default()
1023            },
1024            servers: vec![openapiv3::Server {
1025                url: "https://{prefix}api.example.com".to_string(),
1026                description: Some("Server with empty default".to_string()),
1027                variables: Some(variables),
1028                extensions: indexmap::IndexMap::new(),
1029            }],
1030            ..Default::default()
1031        };
1032
1033        let transformer = SpecTransformer::new();
1034        let cached = transformer.transform("test", &spec).unwrap();
1035
1036        // Verify empty string default is preserved
1037        assert!(cached.server_variables.contains_key("prefix"));
1038        let prefix_var = &cached.server_variables["prefix"];
1039        assert_eq!(prefix_var.default, Some(String::new()));
1040        assert_eq!(prefix_var.description, Some("Optional prefix".to_string()));
1041    }
1042
1043    #[test]
1044    fn test_transform_with_operations() {
1045        let transformer = SpecTransformer::new();
1046        let mut spec = create_test_spec();
1047
1048        let mut path_item = PathItem::default();
1049        path_item.get = Some(Operation {
1050            operation_id: Some("getUsers".to_string()),
1051            tags: vec!["users".to_string()],
1052            description: Some("Get all users".to_string()),
1053            responses: Responses::default(),
1054            ..Default::default()
1055        });
1056
1057        spec.paths
1058            .paths
1059            .insert("/users".to_string(), ReferenceOr::Item(path_item));
1060
1061        let cached = transformer
1062            .transform("test", &spec)
1063            .expect("Transform should succeed");
1064
1065        assert_eq!(cached.commands.len(), 1);
1066        let command = &cached.commands[0];
1067        assert_eq!(command.name, "users");
1068        assert_eq!(command.operation_id, "getUsers");
1069        assert_eq!(command.method, constants::HTTP_METHOD_GET);
1070        assert_eq!(command.path, "/users");
1071        assert_eq!(command.description, Some("Get all users".to_string()));
1072    }
1073
1074    #[test]
1075    fn test_transform_with_parameter_reference() {
1076        let transformer = SpecTransformer::new();
1077        let mut spec = create_test_spec();
1078
1079        // Add a parameter to components
1080        let mut components = Components::default();
1081        let user_id_param = Parameter::Path {
1082            parameter_data: ParameterData {
1083                name: "userId".to_string(),
1084                description: Some("Unique identifier of the user".to_string()),
1085                required: true,
1086                deprecated: Some(false),
1087                format: ParameterSchemaOrContent::Schema(ReferenceOr::Item(Schema {
1088                    schema_data: SchemaData::default(),
1089                    schema_kind: SchemaKind::Type(Type::String(Default::default())),
1090                })),
1091                example: None,
1092                examples: Default::default(),
1093                explode: None,
1094                extensions: Default::default(),
1095            },
1096            style: Default::default(),
1097        };
1098        components
1099            .parameters
1100            .insert("userId".to_string(), ReferenceOr::Item(user_id_param));
1101        spec.components = Some(components);
1102
1103        // Create operation with parameter reference
1104        let mut path_item = PathItem::default();
1105        path_item.get = Some(Operation {
1106            operation_id: Some("getUserById".to_string()),
1107            tags: vec!["users".to_string()],
1108            parameters: vec![ReferenceOr::Reference {
1109                reference: "#/components/parameters/userId".to_string(),
1110            }],
1111            responses: Responses::default(),
1112            ..Default::default()
1113        });
1114
1115        spec.paths
1116            .paths
1117            .insert("/users/{userId}".to_string(), ReferenceOr::Item(path_item));
1118
1119        let cached = transformer
1120            .transform("test", &spec)
1121            .expect("Transform should succeed with parameter reference");
1122
1123        // Verify the parameter was resolved
1124        assert_eq!(cached.commands.len(), 1);
1125        let command = &cached.commands[0];
1126        assert_eq!(command.parameters.len(), 1);
1127        let param = &command.parameters[0];
1128        assert_eq!(param.name, "userId");
1129        assert_eq!(param.location, constants::PARAM_LOCATION_PATH);
1130        assert!(param.required);
1131        assert_eq!(
1132            param.description,
1133            Some("Unique identifier of the user".to_string())
1134        );
1135    }
1136
1137    #[test]
1138    fn test_transform_with_invalid_parameter_reference() {
1139        let transformer = SpecTransformer::new();
1140        let mut spec = create_test_spec();
1141
1142        // Create operation with invalid parameter reference
1143        let mut path_item = PathItem::default();
1144        path_item.get = Some(Operation {
1145            parameters: vec![ReferenceOr::Reference {
1146                reference: "#/invalid/reference/format".to_string(),
1147            }],
1148            responses: Responses::default(),
1149            ..Default::default()
1150        });
1151
1152        spec.paths
1153            .paths
1154            .insert("/users".to_string(), ReferenceOr::Item(path_item));
1155
1156        let result = transformer.transform("test", &spec);
1157        assert!(result.is_err());
1158        match result.unwrap_err() {
1159            crate::error::Error::Internal {
1160                kind: crate::error::ErrorKind::Validation,
1161                message: msg,
1162                ..
1163            } => {
1164                assert!(msg.contains("Invalid parameter reference format"));
1165            }
1166            _ => panic!("Expected Validation error"),
1167        }
1168    }
1169
1170    #[test]
1171    fn test_transform_with_missing_parameter_reference() {
1172        let transformer = SpecTransformer::new();
1173        let mut spec = create_test_spec();
1174
1175        // Add empty components
1176        spec.components = Some(Components::default());
1177
1178        // Create operation with reference to non-existent parameter
1179        let mut path_item = PathItem::default();
1180        path_item.get = Some(Operation {
1181            parameters: vec![ReferenceOr::Reference {
1182                reference: "#/components/parameters/nonExistent".to_string(),
1183            }],
1184            responses: Responses::default(),
1185            ..Default::default()
1186        });
1187
1188        spec.paths
1189            .paths
1190            .insert("/users".to_string(), ReferenceOr::Item(path_item));
1191
1192        let result = transformer.transform("test", &spec);
1193        assert!(result.is_err());
1194        match result.unwrap_err() {
1195            crate::error::Error::Internal {
1196                kind: crate::error::ErrorKind::Validation,
1197                message: msg,
1198                ..
1199            } => {
1200                assert!(msg.contains("Parameter 'nonExistent' not found in components"));
1201            }
1202            _ => panic!("Expected Validation error"),
1203        }
1204    }
1205
1206    #[test]
1207    fn test_transform_with_nested_parameter_reference() {
1208        let transformer = SpecTransformer::new();
1209        let mut spec = create_test_spec();
1210
1211        let mut components = Components::default();
1212
1213        // Add a parameter that references another parameter
1214        components.parameters.insert(
1215            "userIdRef".to_string(),
1216            ReferenceOr::Reference {
1217                reference: "#/components/parameters/userId".to_string(),
1218            },
1219        );
1220
1221        // Add the actual parameter
1222        let user_id_param = Parameter::Path {
1223            parameter_data: ParameterData {
1224                name: "userId".to_string(),
1225                description: Some("User ID parameter".to_string()),
1226                required: true,
1227                deprecated: Some(false),
1228                format: ParameterSchemaOrContent::Schema(ReferenceOr::Item(Schema {
1229                    schema_data: SchemaData::default(),
1230                    schema_kind: SchemaKind::Type(Type::String(Default::default())),
1231                })),
1232                example: None,
1233                examples: Default::default(),
1234                explode: None,
1235                extensions: Default::default(),
1236            },
1237            style: Default::default(),
1238        };
1239        components
1240            .parameters
1241            .insert("userId".to_string(), ReferenceOr::Item(user_id_param));
1242        spec.components = Some(components);
1243
1244        // Create operation with nested parameter reference
1245        let mut path_item = PathItem::default();
1246        path_item.get = Some(Operation {
1247            parameters: vec![ReferenceOr::Reference {
1248                reference: "#/components/parameters/userIdRef".to_string(),
1249            }],
1250            responses: Responses::default(),
1251            ..Default::default()
1252        });
1253
1254        spec.paths
1255            .paths
1256            .insert("/users/{userId}".to_string(), ReferenceOr::Item(path_item));
1257
1258        let cached = transformer
1259            .transform("test", &spec)
1260            .expect("Transform should succeed with nested parameter reference");
1261
1262        // Verify the nested reference was resolved
1263        assert_eq!(cached.commands.len(), 1);
1264        let command = &cached.commands[0];
1265        assert_eq!(command.parameters.len(), 1);
1266        let param = &command.parameters[0];
1267        assert_eq!(param.name, "userId");
1268        assert_eq!(param.description, Some("User ID parameter".to_string()));
1269    }
1270
1271    #[test]
1272    fn test_transform_with_circular_parameter_reference() {
1273        let transformer = SpecTransformer::new();
1274        let mut spec = create_test_spec();
1275
1276        let mut components = Components::default();
1277
1278        // Create direct circular reference: paramA -> paramA
1279        components.parameters.insert(
1280            "paramA".to_string(),
1281            ReferenceOr::Reference {
1282                reference: "#/components/parameters/paramA".to_string(),
1283            },
1284        );
1285
1286        spec.components = Some(components);
1287
1288        // Create operation with circular parameter reference
1289        let mut path_item = PathItem::default();
1290        path_item.get = Some(Operation {
1291            parameters: vec![ReferenceOr::Reference {
1292                reference: "#/components/parameters/paramA".to_string(),
1293            }],
1294            responses: Responses::default(),
1295            ..Default::default()
1296        });
1297
1298        spec.paths
1299            .paths
1300            .insert("/test".to_string(), ReferenceOr::Item(path_item));
1301
1302        let result = transformer.transform("test", &spec);
1303        assert!(result.is_err());
1304        match result.unwrap_err() {
1305            crate::error::Error::Internal {
1306                kind: crate::error::ErrorKind::Validation,
1307                message: msg,
1308                ..
1309            } => {
1310                assert!(
1311                    msg.contains("Circular reference detected"),
1312                    "Error message should mention circular reference: {msg}"
1313                );
1314            }
1315            _ => panic!("Expected Validation error for circular reference"),
1316        }
1317    }
1318
1319    #[test]
1320    fn test_transform_with_indirect_circular_reference() {
1321        let transformer = SpecTransformer::new();
1322        let mut spec = create_test_spec();
1323
1324        let mut components = Components::default();
1325
1326        // Create indirect circular reference: paramA -> paramB -> paramA
1327        components.parameters.insert(
1328            "paramA".to_string(),
1329            ReferenceOr::Reference {
1330                reference: "#/components/parameters/paramB".to_string(),
1331            },
1332        );
1333
1334        components.parameters.insert(
1335            "paramB".to_string(),
1336            ReferenceOr::Reference {
1337                reference: "#/components/parameters/paramA".to_string(),
1338            },
1339        );
1340
1341        spec.components = Some(components);
1342
1343        // Create operation with circular parameter reference
1344        let mut path_item = PathItem::default();
1345        path_item.get = Some(Operation {
1346            parameters: vec![ReferenceOr::Reference {
1347                reference: "#/components/parameters/paramA".to_string(),
1348            }],
1349            responses: Responses::default(),
1350            ..Default::default()
1351        });
1352
1353        spec.paths
1354            .paths
1355            .insert("/test".to_string(), ReferenceOr::Item(path_item));
1356
1357        let result = transformer.transform("test", &spec);
1358        assert!(result.is_err());
1359        match result.unwrap_err() {
1360            crate::error::Error::Internal {
1361                kind: crate::error::ErrorKind::Validation,
1362                message: msg,
1363                ..
1364            } => {
1365                assert!(
1366                    msg.contains("Circular reference detected") || msg.contains("reference cycle"),
1367                    "Error message should mention circular reference: {msg}"
1368                );
1369            }
1370            _ => panic!("Expected Validation error for circular reference"),
1371        }
1372    }
1373
1374    #[test]
1375    fn test_transform_with_complex_circular_reference() {
1376        let transformer = SpecTransformer::new();
1377        let mut spec = create_test_spec();
1378
1379        let mut components = Components::default();
1380
1381        // Create complex circular reference: paramA -> paramB -> paramC -> paramA
1382        components.parameters.insert(
1383            "paramA".to_string(),
1384            ReferenceOr::Reference {
1385                reference: "#/components/parameters/paramB".to_string(),
1386            },
1387        );
1388
1389        components.parameters.insert(
1390            "paramB".to_string(),
1391            ReferenceOr::Reference {
1392                reference: "#/components/parameters/paramC".to_string(),
1393            },
1394        );
1395
1396        components.parameters.insert(
1397            "paramC".to_string(),
1398            ReferenceOr::Reference {
1399                reference: "#/components/parameters/paramA".to_string(),
1400            },
1401        );
1402
1403        spec.components = Some(components);
1404
1405        // Create operation with circular parameter reference
1406        let mut path_item = PathItem::default();
1407        path_item.get = Some(Operation {
1408            parameters: vec![ReferenceOr::Reference {
1409                reference: "#/components/parameters/paramA".to_string(),
1410            }],
1411            responses: Responses::default(),
1412            ..Default::default()
1413        });
1414
1415        spec.paths
1416            .paths
1417            .insert("/test".to_string(), ReferenceOr::Item(path_item));
1418
1419        let result = transformer.transform("test", &spec);
1420        assert!(result.is_err());
1421        match result.unwrap_err() {
1422            crate::error::Error::Internal {
1423                kind: crate::error::ErrorKind::Validation,
1424                message: msg,
1425                ..
1426            } => {
1427                assert!(
1428                    msg.contains("Circular reference detected") || msg.contains("reference cycle"),
1429                    "Error message should mention circular reference: {msg}"
1430                );
1431            }
1432            _ => panic!("Expected Validation error for circular reference"),
1433        }
1434    }
1435
1436    #[test]
1437    fn test_transform_with_depth_limit() {
1438        let transformer = SpecTransformer::new();
1439        let mut spec = create_test_spec();
1440
1441        let mut components = Components::default();
1442
1443        // Create a chain of references that exceeds MAX_REFERENCE_DEPTH
1444        for i in 0..12 {
1445            let param_name = format!("param{i}");
1446            let next_param = format!("param{}", i + 1);
1447
1448            if i < 11 {
1449                // Reference to next parameter
1450                components.parameters.insert(
1451                    param_name,
1452                    ReferenceOr::Reference {
1453                        reference: format!("#/components/parameters/{next_param}"),
1454                    },
1455                );
1456            } else {
1457                // Last parameter is actual parameter definition
1458                let actual_param = Parameter::Path {
1459                    parameter_data: ParameterData {
1460                        name: "deepParam".to_string(),
1461                        description: Some("Very deeply nested parameter".to_string()),
1462                        required: true,
1463                        deprecated: Some(false),
1464                        format: ParameterSchemaOrContent::Schema(ReferenceOr::Item(Schema {
1465                            schema_data: SchemaData::default(),
1466                            schema_kind: SchemaKind::Type(Type::String(Default::default())),
1467                        })),
1468                        example: None,
1469                        examples: Default::default(),
1470                        explode: None,
1471                        extensions: Default::default(),
1472                    },
1473                    style: Default::default(),
1474                };
1475                components
1476                    .parameters
1477                    .insert(param_name, ReferenceOr::Item(actual_param));
1478            }
1479        }
1480
1481        spec.components = Some(components);
1482
1483        // Create operation with deeply nested parameter reference
1484        let mut path_item = PathItem::default();
1485        path_item.get = Some(Operation {
1486            parameters: vec![ReferenceOr::Reference {
1487                reference: "#/components/parameters/param0".to_string(),
1488            }],
1489            responses: Responses::default(),
1490            ..Default::default()
1491        });
1492
1493        spec.paths
1494            .paths
1495            .insert("/test".to_string(), ReferenceOr::Item(path_item));
1496
1497        let result = transformer.transform("test", &spec);
1498        assert!(result.is_err());
1499        match result.unwrap_err() {
1500            crate::error::Error::Internal {
1501                kind: crate::error::ErrorKind::Validation,
1502                message: msg,
1503                ..
1504            } => {
1505                assert!(
1506                    msg.contains("Maximum reference depth") && msg.contains("10"),
1507                    "Error message should mention depth limit: {msg}"
1508                );
1509            }
1510            _ => panic!("Expected Validation error for depth limit"),
1511        }
1512    }
1513}