Skip to main content

aperture_cli/spec/
transformer.rs

1use crate::cache::models::{
2    CachedApertureSecret, CachedCommand, CachedParameter, CachedRequestBody, CachedResponse,
3    CachedSecurityScheme, CachedSpec, CommandExample, PaginationInfo, PaginationStrategy,
4    SkippedEndpoint, CACHE_FORMAT_VERSION,
5};
6use crate::constants;
7use crate::error::Error;
8use crate::utils::to_kebab_case;
9use openapiv3::{OpenAPI, Operation, Parameter, ReferenceOr, RequestBody, SecurityScheme};
10use serde_json;
11use std::collections::HashMap;
12use std::fmt::Write;
13
14/// Type alias for schema type information extracted from a schema kind
15/// Returns: (`schema_type`, `format`, `default_value`, `enum_values`)
16type SchemaTypeInfo = (String, Option<String>, Option<String>, Vec<String>);
17
18/// Type alias for parameter schema information
19/// Returns: (`schema_json`, `schema_type`, `format`, `default_value`, `enum_values`)
20type ParameterSchemaInfo = (
21    Option<String>,
22    Option<String>,
23    Option<String>,
24    Option<String>,
25    Vec<String>,
26);
27
28/// Options for transforming an `OpenAPI` specification
29#[derive(Debug, Clone)]
30pub struct TransformOptions {
31    /// The name of the API
32    pub name: String,
33    /// Endpoints to skip during transformation
34    pub skip_endpoints: Vec<(String, String)>,
35    /// Validation warnings to include in the cached spec
36    pub warnings: Vec<crate::spec::validator::ValidationWarning>,
37}
38
39impl TransformOptions {
40    /// Creates new transform options with the given API name
41    #[must_use]
42    pub fn new(name: impl Into<String>) -> Self {
43        Self {
44            name: name.into(),
45            skip_endpoints: Vec::new(),
46            warnings: Vec::new(),
47        }
48    }
49
50    /// Sets the endpoints to skip
51    #[must_use]
52    pub fn with_skip_endpoints(mut self, endpoints: Vec<(String, String)>) -> Self {
53        self.skip_endpoints = endpoints;
54        self
55    }
56
57    /// Sets the validation warnings
58    #[must_use]
59    pub fn with_warnings(
60        mut self,
61        warnings: Vec<crate::spec::validator::ValidationWarning>,
62    ) -> Self {
63        self.warnings = warnings;
64        self
65    }
66}
67
68/// Transforms `OpenAPI` specifications into Aperture's cached format
69pub struct SpecTransformer;
70
71impl SpecTransformer {
72    /// Creates a new `SpecTransformer` instance
73    #[must_use]
74    pub const fn new() -> Self {
75        Self
76    }
77
78    /// Transforms an `OpenAPI` specification into a cached representation using options
79    ///
80    /// This method converts the full `OpenAPI` spec into an optimized format
81    /// that can be quickly loaded and used for CLI generation.
82    ///
83    /// # Errors
84    ///
85    /// Returns an error if parameter reference resolution fails
86    pub fn transform_with_options(
87        &self,
88        spec: &OpenAPI,
89        options: &TransformOptions,
90    ) -> Result<CachedSpec, Error> {
91        self.transform_with_warnings(
92            &options.name,
93            spec,
94            &options.skip_endpoints,
95            &options.warnings,
96        )
97    }
98
99    /// Transforms an `OpenAPI` specification into a cached representation
100    ///
101    /// This method converts the full `OpenAPI` spec into an optimized format
102    /// that can be quickly loaded and used for CLI generation.
103    ///
104    /// # Errors
105    ///
106    /// Returns an error if parameter reference resolution fails
107    pub fn transform(&self, name: &str, spec: &OpenAPI) -> Result<CachedSpec, Error> {
108        self.transform_with_filter(name, spec, &[])
109    }
110
111    /// Transforms an `OpenAPI` specification into a cached representation with endpoint filtering
112    ///
113    /// This method converts the full `OpenAPI` spec into an optimized format
114    /// that can be quickly loaded and used for CLI generation, filtering out specified endpoints.
115    ///
116    /// # Arguments
117    ///
118    /// * `name` - The name for the cached spec
119    /// * `spec` - The `OpenAPI` specification to transform
120    /// * `skip_endpoints` - List of endpoints to skip (path, method pairs)
121    ///
122    /// # Errors
123    ///
124    /// Returns an error if parameter reference resolution fails
125    pub fn transform_with_filter(
126        &self,
127        name: &str,
128        spec: &OpenAPI,
129        skip_endpoints: &[(String, String)],
130    ) -> Result<CachedSpec, Error> {
131        self.transform_with_warnings(name, spec, skip_endpoints, &[])
132    }
133
134    /// Transforms an `OpenAPI` specification with full warning information
135    ///
136    /// # Arguments
137    ///
138    /// * `name` - The name for the cached spec
139    /// * `spec` - The `OpenAPI` specification to transform
140    /// * `skip_endpoints` - List of endpoints to skip (path, method pairs)
141    /// * `warnings` - Validation warnings to store in the cached spec
142    ///
143    /// # Errors
144    ///
145    /// Returns an error if parameter reference resolution fails
146    pub fn transform_with_warnings(
147        &self,
148        name: &str,
149        spec: &OpenAPI,
150        skip_endpoints: &[(String, String)],
151        warnings: &[crate::spec::validator::ValidationWarning],
152    ) -> Result<CachedSpec, Error> {
153        let mut commands = Vec::new();
154
155        // Extract version from info
156        let version = spec.info.version.clone();
157
158        // Extract server URLs
159        let servers: Vec<String> = spec.servers.iter().map(|s| s.url.clone()).collect();
160        let base_url = servers.first().cloned();
161
162        // Extract server variables from the first server (if any)
163        let server_variables: HashMap<String, crate::cache::models::ServerVariable> = spec
164            .servers
165            .first()
166            .and_then(|server| server.variables.as_ref())
167            .map(|vars| {
168                vars.iter()
169                    .map(|(name, variable)| {
170                        (
171                            name.clone(),
172                            crate::cache::models::ServerVariable {
173                                default: Some(variable.default.clone()),
174                                enum_values: variable.enumeration.clone(),
175                                description: variable.description.clone(),
176                            },
177                        )
178                    })
179                    .collect()
180            })
181            .unwrap_or_default();
182
183        // Extract global security requirements
184        let global_security_requirements: Vec<String> = spec
185            .security
186            .iter()
187            .flat_map(|security_vec| {
188                security_vec
189                    .iter()
190                    .flat_map(|security_req| security_req.keys().cloned())
191            })
192            .collect();
193
194        // Process all paths and operations
195        for (path, path_item) in spec.paths.iter() {
196            Self::process_path_item(
197                spec,
198                path,
199                path_item,
200                skip_endpoints,
201                &global_security_requirements,
202                &mut commands,
203            )?;
204        }
205
206        // Extract security schemes
207        let security_schemes = Self::extract_security_schemes(spec);
208
209        // Convert warnings to skipped endpoints
210        let skipped_endpoints: Vec<SkippedEndpoint> = warnings
211            .iter()
212            .map(|w| SkippedEndpoint {
213                path: w.endpoint.path.clone(),
214                method: w.endpoint.method.clone(),
215                content_type: w.endpoint.content_type.clone(),
216                reason: w.reason.clone(),
217            })
218            .collect();
219
220        Ok(CachedSpec {
221            cache_format_version: CACHE_FORMAT_VERSION,
222            name: name.to_string(),
223            version,
224            commands,
225            base_url,
226            servers,
227            security_schemes,
228            skipped_endpoints,
229            server_variables,
230        })
231    }
232
233    /// Process a single path item and its operations
234    fn process_path_item(
235        spec: &OpenAPI,
236        path: &str,
237        path_item: &ReferenceOr<openapiv3::PathItem>,
238        skip_endpoints: &[(String, String)],
239        global_security_requirements: &[String],
240        commands: &mut Vec<CachedCommand>,
241    ) -> Result<(), Error> {
242        let ReferenceOr::Item(item) = path_item else {
243            return Ok(());
244        };
245
246        // Process each HTTP method
247        for (method, operation) in crate::spec::http_methods_iter(item) {
248            let Some(op) = operation else {
249                continue;
250            };
251
252            if Self::should_skip_endpoint(path, method, skip_endpoints) {
253                continue;
254            }
255
256            let command =
257                Self::transform_operation(spec, method, path, op, global_security_requirements)?;
258            commands.push(command);
259        }
260
261        Ok(())
262    }
263
264    /// Check if an endpoint should be skipped
265    fn should_skip_endpoint(path: &str, method: &str, skip_endpoints: &[(String, String)]) -> bool {
266        skip_endpoints.iter().any(|(skip_path, skip_method)| {
267            skip_path == path && skip_method.eq_ignore_ascii_case(method)
268        })
269    }
270
271    /// Transforms a single operation into a cached command
272    #[allow(clippy::too_many_lines)]
273    fn transform_operation(
274        spec: &OpenAPI,
275        method: &str,
276        path: &str,
277        operation: &Operation,
278        global_security_requirements: &[String],
279    ) -> Result<CachedCommand, Error> {
280        // Extract operation metadata
281        let operation_id = operation
282            .operation_id
283            .clone()
284            .unwrap_or_else(|| format!("{method}_{path}"));
285
286        // Use first tag as command namespace, or "default" if no tags
287        let name = operation
288            .tags
289            .first()
290            .cloned()
291            .unwrap_or_else(|| constants::DEFAULT_GROUP.to_string());
292
293        // Transform parameters
294        let mut parameters = Vec::new();
295        for param_ref in &operation.parameters {
296            match param_ref {
297                ReferenceOr::Item(param) => {
298                    parameters.push(Self::transform_parameter(param));
299                }
300                ReferenceOr::Reference { reference } => {
301                    let param = Self::resolve_parameter_reference(spec, reference)?;
302                    parameters.push(Self::transform_parameter(&param));
303                }
304            }
305        }
306
307        // Transform request body
308        let request_body = operation
309            .request_body
310            .as_ref()
311            .and_then(Self::transform_request_body);
312
313        // Transform responses
314        let responses = operation
315            .responses
316            .responses
317            .iter()
318            .map(|(code, response_ref)| {
319                Self::transform_response(spec, code.to_string(), response_ref)
320            })
321            .collect();
322
323        // Extract security requirements - use operation-level if defined, else global
324        let security_requirements = operation.security.as_ref().map_or_else(
325            || global_security_requirements.to_vec(),
326            |security_reqs| {
327                security_reqs
328                    .iter()
329                    .flat_map(|security_req| security_req.keys().cloned())
330                    .collect()
331            },
332        );
333
334        // Generate examples for this command
335        let examples = Self::generate_command_examples(
336            &name,
337            &operation_id,
338            method,
339            path,
340            &parameters,
341            request_body.as_ref(),
342        );
343
344        // Detect pagination strategy from the spec.
345        let pagination = Self::detect_pagination(operation, spec);
346
347        Ok(CachedCommand {
348            name,
349            description: operation.description.clone(),
350            summary: operation.summary.clone(),
351            operation_id,
352            method: method.to_uppercase(),
353            path: path.to_string(),
354            parameters,
355            request_body,
356            responses,
357            security_requirements,
358            tags: operation.tags.clone(),
359            deprecated: operation.deprecated,
360            external_docs_url: operation
361                .external_docs
362                .as_ref()
363                .map(|docs| docs.url.clone()),
364            examples,
365            display_group: None,
366            display_name: None,
367            aliases: vec![],
368            hidden: false,
369            pagination,
370        })
371    }
372
373    /// Transforms a parameter into cached format
374    #[allow(clippy::too_many_lines)]
375    fn transform_parameter(param: &Parameter) -> CachedParameter {
376        let (param_data, location_str) = match param {
377            Parameter::Query { parameter_data, .. } => {
378                (parameter_data, constants::PARAM_LOCATION_QUERY)
379            }
380            Parameter::Header { parameter_data, .. } => {
381                (parameter_data, constants::PARAM_LOCATION_HEADER)
382            }
383            Parameter::Path { parameter_data, .. } => {
384                (parameter_data, constants::PARAM_LOCATION_PATH)
385            }
386            Parameter::Cookie { parameter_data, .. } => {
387                (parameter_data, constants::PARAM_LOCATION_COOKIE)
388            }
389        };
390
391        // Extract schema information from parameter
392        let (schema_json, schema_type, format, default_value, enum_values) =
393            Self::extract_parameter_schema_info(&param_data.format);
394
395        // Extract example value
396        let example = param_data
397            .example
398            .as_ref()
399            .map(|ex| serde_json::to_string(ex).unwrap_or_else(|_| ex.to_string()));
400
401        CachedParameter {
402            name: param_data.name.clone(),
403            location: location_str.to_string(),
404            required: param_data.required,
405            description: param_data.description.clone(),
406            schema: schema_json,
407            schema_type,
408            format,
409            default_value,
410            enum_values,
411            example,
412        }
413    }
414
415    /// Extracts schema information from parameter schema or content
416    fn extract_parameter_schema_info(
417        format: &openapiv3::ParameterSchemaOrContent,
418    ) -> ParameterSchemaInfo {
419        let openapiv3::ParameterSchemaOrContent::Schema(schema_ref) = format else {
420            // No schema provided, use defaults
421            return (
422                Some(r#"{"type": "string"}"#.to_string()),
423                Some(constants::SCHEMA_TYPE_STRING.to_string()),
424                None,
425                None,
426                vec![],
427            );
428        };
429
430        match schema_ref {
431            ReferenceOr::Item(schema) => {
432                let schema_json = serde_json::to_string(schema).ok();
433
434                // Extract type information
435                let (schema_type, format, default, enums) =
436                    Self::extract_schema_type_info(&schema.schema_kind);
437
438                // Extract default value if present
439                let default_value = schema
440                    .schema_data
441                    .default
442                    .as_ref()
443                    .map(|v| serde_json::to_string(v).unwrap_or_else(|_| v.to_string()));
444
445                (
446                    schema_json,
447                    Some(schema_type),
448                    format,
449                    default_value.or(default),
450                    enums,
451                )
452            }
453            ReferenceOr::Reference { .. } => {
454                // For references, use basic defaults
455                (
456                    Some(r#"{"type": "string"}"#.to_string()),
457                    Some(constants::SCHEMA_TYPE_STRING.to_string()),
458                    None,
459                    None,
460                    vec![],
461                )
462            }
463        }
464    }
465
466    /// Extracts type information from schema kind
467    fn extract_schema_type_info(schema_kind: &openapiv3::SchemaKind) -> SchemaTypeInfo {
468        let openapiv3::SchemaKind::Type(type_val) = schema_kind else {
469            return (
470                constants::SCHEMA_TYPE_STRING.to_string(),
471                None,
472                None,
473                vec![],
474            );
475        };
476
477        match type_val {
478            openapiv3::Type::String(string_type) => Self::extract_string_type_info(string_type),
479            openapiv3::Type::Number(number_type) => Self::extract_number_type_info(number_type),
480            openapiv3::Type::Integer(integer_type) => Self::extract_integer_type_info(integer_type),
481            openapiv3::Type::Boolean(_) => (
482                constants::SCHEMA_TYPE_BOOLEAN.to_string(),
483                None,
484                None,
485                vec![],
486            ),
487            openapiv3::Type::Array(_) => {
488                (constants::SCHEMA_TYPE_ARRAY.to_string(), None, None, vec![])
489            }
490            openapiv3::Type::Object(_) => (
491                constants::SCHEMA_TYPE_OBJECT.to_string(),
492                None,
493                None,
494                vec![],
495            ),
496        }
497    }
498
499    /// Extracts information from a string type schema
500    fn extract_string_type_info(
501        string_type: &openapiv3::StringType,
502    ) -> (String, Option<String>, Option<String>, Vec<String>) {
503        let enum_values: Vec<String> = string_type
504            .enumeration
505            .iter()
506            .filter_map(|v| v.as_ref())
507            .map(|v| serde_json::to_string(v).unwrap_or_else(|_| v.clone()))
508            .collect();
509
510        let format = Self::extract_format_string(&string_type.format);
511
512        (
513            constants::SCHEMA_TYPE_STRING.to_string(),
514            format,
515            None,
516            enum_values,
517        )
518    }
519
520    /// Extracts information from a number type schema
521    fn extract_number_type_info(
522        number_type: &openapiv3::NumberType,
523    ) -> (String, Option<String>, Option<String>, Vec<String>) {
524        let format = match &number_type.format {
525            openapiv3::VariantOrUnknownOrEmpty::Item(fmt) => Some(format!("{fmt:?}")),
526            _ => None,
527        };
528        ("number".to_string(), format, None, vec![])
529    }
530
531    /// Extracts information from an integer type schema
532    fn extract_integer_type_info(
533        integer_type: &openapiv3::IntegerType,
534    ) -> (String, Option<String>, Option<String>, Vec<String>) {
535        let format = match &integer_type.format {
536            openapiv3::VariantOrUnknownOrEmpty::Item(fmt) => Some(format!("{fmt:?}")),
537            _ => None,
538        };
539        (
540            constants::SCHEMA_TYPE_INTEGER.to_string(),
541            format,
542            None,
543            vec![],
544        )
545    }
546
547    /// Extracts format string from a variant or unknown or empty type
548    fn extract_format_string(
549        format: &openapiv3::VariantOrUnknownOrEmpty<openapiv3::StringFormat>,
550    ) -> Option<String> {
551        match format {
552            openapiv3::VariantOrUnknownOrEmpty::Item(fmt) => Some(format!("{fmt:?}")),
553            _ => None,
554        }
555    }
556
557    /// Transforms a response into cached format with schema reference resolution
558    fn transform_response(
559        spec: &OpenAPI,
560        status_code: String,
561        response_ref: &ReferenceOr<openapiv3::Response>,
562    ) -> CachedResponse {
563        let ReferenceOr::Item(response) = response_ref else {
564            return CachedResponse {
565                status_code,
566                description: None,
567                content_type: None,
568                schema: None,
569                example: None,
570            };
571        };
572
573        // Get description
574        let description = if response.description.is_empty() {
575            None
576        } else {
577            Some(response.description.clone())
578        };
579
580        // Prefer application/json content type, otherwise use first available
581        let preferred_content_type = if response.content.contains_key(constants::CONTENT_TYPE_JSON)
582        {
583            Some(constants::CONTENT_TYPE_JSON)
584        } else {
585            response.content.keys().next().map(String::as_str)
586        };
587
588        let (content_type, schema, example) =
589            preferred_content_type.map_or((None, None, None), |ct| {
590                let media_type = response.content.get(ct);
591                let schema = media_type.and_then(|mt| {
592                    mt.schema
593                        .as_ref()
594                        .and_then(|schema_ref| Self::resolve_and_serialize_schema(spec, schema_ref))
595                });
596
597                // Extract example from media type
598                let example = media_type.and_then(|mt| {
599                    mt.example
600                        .as_ref()
601                        .map(|ex| serde_json::to_string(ex).unwrap_or_else(|_| ex.to_string()))
602                });
603
604                (Some(ct.to_string()), schema, example)
605            });
606
607        CachedResponse {
608            status_code,
609            description,
610            content_type,
611            schema,
612            example,
613        }
614    }
615
616    /// Resolves a schema reference (if applicable) and serializes to JSON string
617    fn resolve_and_serialize_schema(
618        spec: &OpenAPI,
619        schema_ref: &ReferenceOr<openapiv3::Schema>,
620    ) -> Option<String> {
621        match schema_ref {
622            ReferenceOr::Item(schema) => serde_json::to_string(schema).ok(),
623            ReferenceOr::Reference { reference } => {
624                // Attempt to resolve the reference
625                crate::spec::resolve_schema_reference(spec, reference)
626                    .ok()
627                    .and_then(|schema| serde_json::to_string(&schema).ok())
628            }
629        }
630    }
631
632    /// Transforms a request body into cached format
633    fn transform_request_body(
634        request_body: &ReferenceOr<RequestBody>,
635    ) -> Option<CachedRequestBody> {
636        match request_body {
637            ReferenceOr::Item(body) => {
638                // Prefer JSON content if available
639                let content_type = if body.content.contains_key(constants::CONTENT_TYPE_JSON) {
640                    constants::CONTENT_TYPE_JSON
641                } else {
642                    body.content.keys().next()?
643                };
644
645                // Extract schema and example from the content
646                let media_type = body.content.get(content_type)?;
647                let schema = media_type
648                    .schema
649                    .as_ref()
650                    .and_then(|schema_ref| match schema_ref {
651                        ReferenceOr::Item(schema) => serde_json::to_string(schema).ok(),
652                        ReferenceOr::Reference { .. } => None,
653                    })
654                    .unwrap_or_else(|| "{}".to_string());
655
656                let example = media_type
657                    .example
658                    .as_ref()
659                    .map(|ex| serde_json::to_string(ex).unwrap_or_else(|_| ex.to_string()));
660
661                Some(CachedRequestBody {
662                    content_type: content_type.to_string(),
663                    schema,
664                    required: body.required,
665                    description: body.description.clone(),
666                    example,
667                })
668            }
669            ReferenceOr::Reference { .. } => None, // Skip references for now
670        }
671    }
672
673    /// Extracts and transforms security schemes from the `OpenAPI` spec
674    fn extract_security_schemes(spec: &OpenAPI) -> HashMap<String, CachedSecurityScheme> {
675        let mut security_schemes = HashMap::new();
676
677        let Some(components) = &spec.components else {
678            return security_schemes;
679        };
680
681        for (name, scheme_ref) in &components.security_schemes {
682            let ReferenceOr::Item(scheme) = scheme_ref else {
683                continue;
684            };
685
686            let Some(cached_scheme) = Self::transform_security_scheme(name, scheme) else {
687                continue;
688            };
689
690            security_schemes.insert(name.clone(), cached_scheme);
691        }
692
693        security_schemes
694    }
695
696    /// Transforms a single security scheme into cached format
697    fn transform_security_scheme(
698        name: &str,
699        scheme: &SecurityScheme,
700    ) -> Option<CachedSecurityScheme> {
701        match scheme {
702            SecurityScheme::APIKey {
703                location,
704                name: param_name,
705                description,
706                ..
707            } => {
708                let aperture_secret = Self::extract_aperture_secret(scheme);
709                let location_str = match location {
710                    openapiv3::APIKeyLocation::Query => constants::PARAM_LOCATION_QUERY,
711                    openapiv3::APIKeyLocation::Header => constants::PARAM_LOCATION_HEADER,
712                    openapiv3::APIKeyLocation::Cookie => constants::PARAM_LOCATION_COOKIE,
713                };
714
715                Some(CachedSecurityScheme {
716                    name: name.to_string(),
717                    scheme_type: constants::AUTH_SCHEME_APIKEY.to_string(),
718                    scheme: None,
719                    location: Some(location_str.to_string()),
720                    parameter_name: Some(param_name.clone()),
721                    description: description.clone(),
722                    bearer_format: None,
723                    aperture_secret,
724                })
725            }
726            SecurityScheme::HTTP {
727                scheme: http_scheme,
728                bearer_format,
729                description,
730                ..
731            } => {
732                let aperture_secret = Self::extract_aperture_secret(scheme);
733                Some(CachedSecurityScheme {
734                    name: name.to_string(),
735                    scheme_type: constants::SECURITY_TYPE_HTTP.to_string(),
736                    scheme: Some(http_scheme.clone()),
737                    location: Some(constants::LOCATION_HEADER.to_string()),
738                    parameter_name: Some(constants::HEADER_AUTHORIZATION.to_string()),
739                    description: description.clone(),
740                    bearer_format: bearer_format.clone(),
741                    aperture_secret,
742                })
743            }
744            // OAuth2 and OpenID Connect should be rejected in validation
745            SecurityScheme::OAuth2 { .. } | SecurityScheme::OpenIDConnect { .. } => None,
746        }
747    }
748
749    /// Extracts x-aperture-secret extension from a security scheme
750    fn extract_aperture_secret(scheme: &SecurityScheme) -> Option<CachedApertureSecret> {
751        // Get extensions from the security scheme
752        let extensions = match scheme {
753            SecurityScheme::APIKey { extensions, .. } | SecurityScheme::HTTP { extensions, .. } => {
754                extensions
755            }
756            SecurityScheme::OAuth2 { .. } | SecurityScheme::OpenIDConnect { .. } => return None,
757        };
758
759        // Parse the x-aperture-secret extension
760        extensions
761            .get(crate::constants::EXT_APERTURE_SECRET)
762            .and_then(|value| {
763                // The extension should be an object with "source" and "name" fields
764                let obj = value.as_object()?;
765                let source = obj.get(crate::constants::EXT_KEY_SOURCE)?.as_str()?;
766                let name = obj.get(crate::constants::EXT_KEY_NAME)?.as_str()?;
767
768                // Currently only "env" source is supported
769                if source != constants::SOURCE_ENV {
770                    return None;
771                }
772
773                Some(CachedApertureSecret {
774                    source: source.to_string(),
775                    name: name.to_string(),
776                })
777            })
778    }
779
780    /// Resolves a parameter reference to its actual parameter definition
781    fn resolve_parameter_reference(spec: &OpenAPI, reference: &str) -> Result<Parameter, Error> {
782        crate::spec::resolve_parameter_reference(spec, reference)
783    }
784
785    /// Generate examples for a command
786    #[allow(clippy::too_many_lines)]
787    fn generate_command_examples(
788        tag: &str,
789        operation_id: &str,
790        method: &str,
791        path: &str,
792        parameters: &[CachedParameter],
793        request_body: Option<&CachedRequestBody>,
794    ) -> Vec<CommandExample> {
795        let mut examples = Vec::new();
796        let operation_kebab = to_kebab_case(operation_id);
797        let tag_kebab = to_kebab_case(tag);
798
799        // Build base command
800        let base_cmd = format!("aperture api myapi {tag_kebab} {operation_kebab}");
801
802        // Example 1: Simple required parameters only
803        let required_params: Vec<&CachedParameter> =
804            parameters.iter().filter(|p| p.required).collect();
805
806        if !required_params.is_empty() {
807            let mut cmd = base_cmd.clone();
808            for param in &required_params {
809                write!(
810                    &mut cmd,
811                    " --{} {}",
812                    param.name,
813                    param.example.as_deref().unwrap_or("<value>")
814                )
815                .expect("writing to String cannot fail");
816            }
817
818            examples.push(CommandExample {
819                description: "Basic usage with required parameters".to_string(),
820                command_line: cmd,
821                explanation: Some(format!("{method} {path}")),
822            });
823        }
824
825        // Example 2: With request body if present
826        if let Some(_body) = request_body {
827            let mut cmd = base_cmd.clone();
828
829            // Add required path/query parameters (only path and query params)
830            let path_query_params = required_params
831                .iter()
832                .filter(|p| p.location == "path" || p.location == "query");
833
834            for param in path_query_params {
835                write!(
836                    &mut cmd,
837                    " --{} {}",
838                    param.name,
839                    param.example.as_deref().unwrap_or("123")
840                )
841                .expect("writing to String cannot fail");
842            }
843
844            // Add body example
845            cmd.push_str(r#" --body '{"name": "example", "value": 42}'"#);
846
847            examples.push(CommandExample {
848                description: "With request body".to_string(),
849                command_line: cmd,
850                explanation: Some("Sends JSON data in the request body".to_string()),
851            });
852        }
853
854        // Example 3: With optional parameters
855        let optional_params: Vec<&CachedParameter> = parameters
856            .iter()
857            .filter(|p| !p.required && p.location == "query")
858            .take(2) // Limit to 2 optional params for brevity
859            .collect();
860
861        if !optional_params.is_empty() && !required_params.is_empty() {
862            let mut cmd = base_cmd.clone();
863
864            // Add required parameters
865            for param in &required_params {
866                write!(
867                    &mut cmd,
868                    " --{} {}",
869                    param.name,
870                    param.example.as_deref().unwrap_or("value")
871                )
872                .expect("writing to String cannot fail");
873            }
874
875            // Add optional parameters
876            for param in &optional_params {
877                write!(
878                    &mut cmd,
879                    " --{} {}",
880                    param.name,
881                    param.example.as_deref().unwrap_or("optional")
882                )
883                .expect("writing to String cannot fail");
884            }
885
886            examples.push(CommandExample {
887                description: "With optional parameters".to_string(),
888                command_line: cmd,
889                explanation: Some(
890                    "Includes optional query parameters for filtering or customization".to_string(),
891                ),
892            });
893        }
894
895        // If no examples were generated, create a simple one
896        if examples.is_empty() {
897            examples.push(CommandExample {
898                description: "Basic usage".to_string(),
899                command_line: base_cmd,
900                explanation: Some(format!("Executes {method} {path}")),
901            });
902        }
903
904        examples
905    }
906
907    /// Detects the pagination strategy for an operation from the `OpenAPI` spec.
908    ///
909    /// Priority order:
910    /// 1. Explicit `x-aperture-pagination` extension on the operation object.
911    /// 2. RFC 5988 `Link` header declared in the operation's successful responses.
912    /// 3. Cursor heuristic: response schema contains a known cursor field name.
913    /// 4. Offset heuristic: operation has a `page`, `offset`, or `skip` query parameter.
914    fn detect_pagination(operation: &Operation, spec: &OpenAPI) -> PaginationInfo {
915        // 1. Explicit x-aperture-pagination extension
916        let explicit = operation
917            .extensions
918            .get(constants::EXT_APERTURE_PAGINATION)
919            .and_then(parse_aperture_pagination_extension);
920        if let Some(info) = explicit {
921            return info;
922        }
923
924        // 2. RFC 5988 Link header declared in successful responses
925        if has_link_header_in_responses(&operation.responses, spec) {
926            return PaginationInfo {
927                strategy: PaginationStrategy::LinkHeader,
928                ..Default::default()
929            };
930        }
931
932        // 3. Cursor heuristic: check response schemas for well-known cursor fields
933        if let Some(info) = detect_cursor_from_responses(&operation.responses, spec) {
934            return info;
935        }
936
937        // 4. Offset heuristic: look for page/offset/skip query parameters
938        if let Some(info) = detect_offset_from_parameters(&operation.parameters, spec) {
939            return info;
940        }
941
942        PaginationInfo::default()
943    }
944}
945
946impl Default for SpecTransformer {
947    fn default() -> Self {
948        Self::new()
949    }
950}
951
952// ── Pagination detection helpers ─────────────────────────────────────────────
953
954/// Parses the `x-aperture-pagination` extension object into a `PaginationInfo`.
955///
956/// Expected JSON shape:
957/// ```json
958/// {
959///   "strategy": "cursor",
960///   "cursor_field": "next_cursor",
961///   "cursor_param": "after"
962/// }
963/// ```
964/// or for offset:
965/// ```json
966/// { "strategy": "offset", "page_param": "page", "limit_param": "limit" }
967/// ```
968fn parse_aperture_pagination_extension(value: &serde_json::Value) -> Option<PaginationInfo> {
969    let obj = value.as_object()?;
970    let strategy_str = obj.get("strategy")?.as_str()?;
971
972    match strategy_str {
973        constants::PAGINATION_STRATEGY_CURSOR => {
974            let cursor_field = obj
975                .get("cursor_field")
976                .and_then(|v| v.as_str())
977                .map(String::from);
978            let cursor_param = obj
979                .get("cursor_param")
980                .and_then(|v| v.as_str())
981                .map(String::from);
982            // Default cursor_param to the cursor_field name when not specified
983            let cursor_param = cursor_param.or_else(|| cursor_field.clone());
984            Some(PaginationInfo {
985                strategy: PaginationStrategy::Cursor,
986                cursor_field,
987                cursor_param,
988                ..Default::default()
989            })
990        }
991        constants::PAGINATION_STRATEGY_OFFSET => {
992            let page_param = obj
993                .get("page_param")
994                .and_then(|v| v.as_str())
995                .map(String::from);
996            let limit_param = obj
997                .get("limit_param")
998                .and_then(|v| v.as_str())
999                .map(String::from);
1000            Some(PaginationInfo {
1001                strategy: PaginationStrategy::Offset,
1002                page_param,
1003                limit_param,
1004                ..Default::default()
1005            })
1006        }
1007        constants::PAGINATION_STRATEGY_LINK_HEADER => Some(PaginationInfo {
1008            strategy: PaginationStrategy::LinkHeader,
1009            ..Default::default()
1010        }),
1011        _ => None,
1012    }
1013}
1014
1015/// Returns `true` if any successful response for this operation declares a
1016/// `Link` header, which indicates RFC 5988 Link-header-based pagination.
1017///
1018/// Only inline response objects are inspected; `$ref` responses are skipped.
1019/// Use `x-aperture-pagination` for specs that define shared response components.
1020fn has_link_header_in_responses(responses: &openapiv3::Responses, _spec: &OpenAPI) -> bool {
1021    constants::SUCCESS_STATUS_CODES.iter().any(|code| {
1022        let status =
1023            openapiv3::StatusCode::Code(code.parse().expect("hard-coded status codes are valid"));
1024        responses
1025            .responses
1026            .get(&status)
1027            .and_then(|r| {
1028                let openapiv3::ReferenceOr::Item(resp) = r else {
1029                    return None;
1030                };
1031                Some(
1032                    resp.headers
1033                        .keys()
1034                        .any(|k| k.eq_ignore_ascii_case(constants::HEADER_LINK)),
1035                )
1036            })
1037            .unwrap_or(false)
1038    })
1039}
1040
1041/// Checks the response schemas for well-known cursor field names.
1042///
1043/// Returns `Some(PaginationInfo)` with `strategy = Cursor` on the first match.
1044///
1045/// Schema `$ref`s within a response are resolved, but `$ref` response objects
1046/// themselves are skipped. Use `x-aperture-pagination` for specs that reference
1047/// shared response components.
1048fn detect_cursor_from_responses(
1049    responses: &openapiv3::Responses,
1050    spec: &OpenAPI,
1051) -> Option<PaginationInfo> {
1052    for code in constants::SUCCESS_STATUS_CODES {
1053        let status =
1054            openapiv3::StatusCode::Code(code.parse().expect("hard-coded status codes are valid"));
1055        let Some(response_ref) = responses.responses.get(&status) else {
1056            continue;
1057        };
1058        let openapiv3::ReferenceOr::Item(response) = response_ref else {
1059            continue;
1060        };
1061
1062        // Prefer JSON; fall back to first available content type.
1063        let content_type = response
1064            .content
1065            .contains_key(constants::CONTENT_TYPE_JSON)
1066            .then_some(constants::CONTENT_TYPE_JSON)
1067            .or_else(|| response.content.keys().next().map(String::as_str));
1068        let Some(content_type) = content_type else {
1069            continue;
1070        };
1071
1072        let Some(media_type) = response.content.get(content_type) else {
1073            continue;
1074        };
1075        let Some(schema_ref) = &media_type.schema else {
1076            continue;
1077        };
1078
1079        // Resolve $ref if necessary
1080        let schema = match schema_ref {
1081            openapiv3::ReferenceOr::Item(s) => std::borrow::Cow::Borrowed(s),
1082            openapiv3::ReferenceOr::Reference { reference } => {
1083                let Ok(resolved) = crate::spec::resolve_schema_reference(spec, reference) else {
1084                    continue;
1085                };
1086                std::borrow::Cow::Owned(resolved)
1087            }
1088        };
1089
1090        // Look for cursor fields in the object's properties.
1091        if let Some(found) = find_cursor_field_in_schema(&schema.schema_kind) {
1092            return Some(PaginationInfo {
1093                strategy: PaginationStrategy::Cursor,
1094                cursor_field: Some(found.to_string()),
1095                cursor_param: Some(found.to_string()),
1096                ..Default::default()
1097            });
1098        }
1099    }
1100    None
1101}
1102
1103/// Returns the first matching cursor field name found in an object schema, or `None`.
1104fn find_cursor_field_in_schema(schema_kind: &openapiv3::SchemaKind) -> Option<&'static str> {
1105    let openapiv3::SchemaKind::Type(openapiv3::Type::Object(obj)) = schema_kind else {
1106        return None;
1107    };
1108    constants::PAGINATION_CURSOR_FIELDS
1109        .iter()
1110        .copied()
1111        .find(|field| obj.properties.contains_key(*field))
1112}
1113
1114/// Checks operation query parameters for offset/page-based pagination signals.
1115fn detect_offset_from_parameters(
1116    params: &[openapiv3::ReferenceOr<openapiv3::Parameter>],
1117    spec: &OpenAPI,
1118) -> Option<PaginationInfo> {
1119    let mut page_param: Option<String> = None;
1120    let mut limit_param: Option<String> = None;
1121
1122    for param_ref in params {
1123        let param = match param_ref {
1124            openapiv3::ReferenceOr::Item(p) => std::borrow::Cow::Borrowed(p),
1125            openapiv3::ReferenceOr::Reference { reference } => {
1126                let Ok(resolved) = crate::spec::resolve_parameter_reference(spec, reference) else {
1127                    continue;
1128                };
1129                std::borrow::Cow::Owned(resolved)
1130            }
1131        };
1132
1133        // Only consider query parameters
1134        let openapiv3::Parameter::Query { parameter_data, .. } = param.as_ref() else {
1135            continue;
1136        };
1137
1138        let name = parameter_data.name.as_str();
1139        match () {
1140            () if constants::PAGINATION_PAGE_PARAMS.contains(&name) => {
1141                page_param = Some(name.to_string());
1142            }
1143            () if constants::PAGINATION_LIMIT_PARAMS.contains(&name) => {
1144                limit_param = Some(name.to_string());
1145            }
1146            () => {}
1147        }
1148    }
1149
1150    if page_param.is_some() {
1151        return Some(PaginationInfo {
1152            strategy: PaginationStrategy::Offset,
1153            page_param,
1154            limit_param,
1155            ..Default::default()
1156        });
1157    }
1158
1159    None
1160}
1161
1162#[cfg(test)]
1163#[allow(clippy::default_trait_access)]
1164#[allow(clippy::field_reassign_with_default)]
1165#[allow(clippy::too_many_lines)]
1166mod tests {
1167    use super::*;
1168    use openapiv3::{
1169        Components, Info, OpenAPI, Operation, Parameter, ParameterData, ParameterSchemaOrContent,
1170        PathItem, ReferenceOr, Responses, Schema, SchemaData, SchemaKind, Type,
1171    };
1172
1173    fn create_test_spec() -> OpenAPI {
1174        OpenAPI {
1175            openapi: "3.0.0".to_string(),
1176            info: Info {
1177                title: "Test API".to_string(),
1178                version: "1.0.0".to_string(),
1179                ..Default::default()
1180            },
1181            servers: vec![openapiv3::Server {
1182                url: "https://api.example.com".to_string(),
1183                ..Default::default()
1184            }],
1185            paths: Default::default(),
1186            ..Default::default()
1187        }
1188    }
1189
1190    #[test]
1191    fn test_transform_basic_spec() {
1192        let transformer = SpecTransformer::new();
1193        let spec = create_test_spec();
1194        let cached = transformer
1195            .transform("test", &spec)
1196            .expect("Transform should succeed");
1197
1198        assert_eq!(cached.name, "test");
1199        assert_eq!(cached.version, "1.0.0");
1200        assert_eq!(cached.base_url, Some("https://api.example.com".to_string()));
1201        assert_eq!(cached.servers.len(), 1);
1202        assert!(cached.commands.is_empty());
1203        assert!(cached.server_variables.is_empty());
1204    }
1205
1206    #[test]
1207    fn test_transform_spec_with_server_variables() {
1208        let mut variables = indexmap::IndexMap::new();
1209        variables.insert(
1210            "region".to_string(),
1211            openapiv3::ServerVariable {
1212                default: "us".to_string(),
1213                description: Some("The regional instance".to_string()),
1214                enumeration: vec!["us".to_string(), "eu".to_string()],
1215                extensions: indexmap::IndexMap::new(),
1216            },
1217        );
1218
1219        let spec = OpenAPI {
1220            openapi: "3.0.0".to_string(),
1221            info: Info {
1222                title: "Test API".to_string(),
1223                version: "1.0.0".to_string(),
1224                ..Default::default()
1225            },
1226            servers: vec![openapiv3::Server {
1227                url: "https://{region}.api.example.com".to_string(),
1228                description: Some("Regional server".to_string()),
1229                variables: Some(variables),
1230                extensions: indexmap::IndexMap::new(),
1231            }],
1232            ..Default::default()
1233        };
1234
1235        let transformer = SpecTransformer::new();
1236        let cached = transformer.transform("test", &spec).unwrap();
1237
1238        // Test server variable extraction
1239        assert_eq!(cached.server_variables.len(), 1);
1240        assert!(cached.server_variables.contains_key("region"));
1241
1242        let region_var = &cached.server_variables["region"];
1243        assert_eq!(region_var.default, Some("us".to_string()));
1244        assert_eq!(
1245            region_var.description,
1246            Some("The regional instance".to_string())
1247        );
1248        assert_eq!(
1249            region_var.enum_values,
1250            vec!["us".to_string(), "eu".to_string()]
1251        );
1252
1253        // Basic spec info
1254        assert_eq!(cached.name, "test");
1255        assert_eq!(
1256            cached.base_url,
1257            Some("https://{region}.api.example.com".to_string())
1258        );
1259    }
1260
1261    #[test]
1262    fn test_transform_spec_with_empty_default_server_variable() {
1263        let mut variables = indexmap::IndexMap::new();
1264        variables.insert(
1265            "prefix".to_string(),
1266            openapiv3::ServerVariable {
1267                default: String::new(), // Empty string default should be preserved
1268                description: Some("Optional prefix".to_string()),
1269                enumeration: vec![],
1270                extensions: indexmap::IndexMap::new(),
1271            },
1272        );
1273
1274        let spec = OpenAPI {
1275            openapi: "3.0.0".to_string(),
1276            info: Info {
1277                title: "Test API".to_string(),
1278                version: "1.0.0".to_string(),
1279                ..Default::default()
1280            },
1281            servers: vec![openapiv3::Server {
1282                url: "https://{prefix}api.example.com".to_string(),
1283                description: Some("Server with empty default".to_string()),
1284                variables: Some(variables),
1285                extensions: indexmap::IndexMap::new(),
1286            }],
1287            ..Default::default()
1288        };
1289
1290        let transformer = SpecTransformer::new();
1291        let cached = transformer.transform("test", &spec).unwrap();
1292
1293        // Verify empty string default is preserved
1294        assert!(cached.server_variables.contains_key("prefix"));
1295        let prefix_var = &cached.server_variables["prefix"];
1296        assert_eq!(prefix_var.default, Some(String::new()));
1297        assert_eq!(prefix_var.description, Some("Optional prefix".to_string()));
1298    }
1299
1300    #[test]
1301    fn test_transform_with_operations() {
1302        let transformer = SpecTransformer::new();
1303        let mut spec = create_test_spec();
1304
1305        let mut path_item = PathItem::default();
1306        path_item.get = Some(Operation {
1307            operation_id: Some("getUsers".to_string()),
1308            tags: vec!["users".to_string()],
1309            description: Some("Get all users".to_string()),
1310            responses: Responses::default(),
1311            ..Default::default()
1312        });
1313
1314        spec.paths
1315            .paths
1316            .insert("/users".to_string(), ReferenceOr::Item(path_item));
1317
1318        let cached = transformer
1319            .transform("test", &spec)
1320            .expect("Transform should succeed");
1321
1322        assert_eq!(cached.commands.len(), 1);
1323        let command = &cached.commands[0];
1324        assert_eq!(command.name, "users");
1325        assert_eq!(command.operation_id, "getUsers");
1326        assert_eq!(command.method, constants::HTTP_METHOD_GET);
1327        assert_eq!(command.path, "/users");
1328        assert_eq!(command.description, Some("Get all users".to_string()));
1329    }
1330
1331    #[test]
1332    fn test_transform_with_parameter_reference() {
1333        let transformer = SpecTransformer::new();
1334        let mut spec = create_test_spec();
1335
1336        // Add a parameter to components
1337        let mut components = Components::default();
1338        let user_id_param = Parameter::Path {
1339            parameter_data: ParameterData {
1340                name: "userId".to_string(),
1341                description: Some("Unique identifier of the user".to_string()),
1342                required: true,
1343                deprecated: Some(false),
1344                format: ParameterSchemaOrContent::Schema(ReferenceOr::Item(Schema {
1345                    schema_data: SchemaData::default(),
1346                    schema_kind: SchemaKind::Type(Type::String(Default::default())),
1347                })),
1348                example: None,
1349                examples: Default::default(),
1350                explode: None,
1351                extensions: Default::default(),
1352            },
1353            style: Default::default(),
1354        };
1355        components
1356            .parameters
1357            .insert("userId".to_string(), ReferenceOr::Item(user_id_param));
1358        spec.components = Some(components);
1359
1360        // Create operation with parameter reference
1361        let mut path_item = PathItem::default();
1362        path_item.get = Some(Operation {
1363            operation_id: Some("getUserById".to_string()),
1364            tags: vec!["users".to_string()],
1365            parameters: vec![ReferenceOr::Reference {
1366                reference: "#/components/parameters/userId".to_string(),
1367            }],
1368            responses: Responses::default(),
1369            ..Default::default()
1370        });
1371
1372        spec.paths
1373            .paths
1374            .insert("/users/{userId}".to_string(), ReferenceOr::Item(path_item));
1375
1376        let cached = transformer
1377            .transform("test", &spec)
1378            .expect("Transform should succeed with parameter reference");
1379
1380        // Verify the parameter was resolved
1381        assert_eq!(cached.commands.len(), 1);
1382        let command = &cached.commands[0];
1383        assert_eq!(command.parameters.len(), 1);
1384        let param = &command.parameters[0];
1385        assert_eq!(param.name, "userId");
1386        assert_eq!(param.location, constants::PARAM_LOCATION_PATH);
1387        assert!(param.required);
1388        assert_eq!(
1389            param.description,
1390            Some("Unique identifier of the user".to_string())
1391        );
1392    }
1393
1394    #[test]
1395    fn test_transform_with_invalid_parameter_reference() {
1396        let transformer = SpecTransformer::new();
1397        let mut spec = create_test_spec();
1398
1399        // Create operation with invalid parameter reference
1400        let mut path_item = PathItem::default();
1401        path_item.get = Some(Operation {
1402            parameters: vec![ReferenceOr::Reference {
1403                reference: "#/invalid/reference/format".to_string(),
1404            }],
1405            responses: Responses::default(),
1406            ..Default::default()
1407        });
1408
1409        spec.paths
1410            .paths
1411            .insert("/users".to_string(), ReferenceOr::Item(path_item));
1412
1413        let result = transformer.transform("test", &spec);
1414        assert!(result.is_err());
1415        match result.unwrap_err() {
1416            crate::error::Error::Internal {
1417                kind: crate::error::ErrorKind::Validation,
1418                message: msg,
1419                ..
1420            } => {
1421                assert!(msg.contains("Invalid parameter reference format"));
1422            }
1423            _ => panic!("Expected Validation error"),
1424        }
1425    }
1426
1427    #[test]
1428    fn test_transform_with_missing_parameter_reference() {
1429        let transformer = SpecTransformer::new();
1430        let mut spec = create_test_spec();
1431
1432        // Add empty components
1433        spec.components = Some(Components::default());
1434
1435        // Create operation with reference to non-existent parameter
1436        let mut path_item = PathItem::default();
1437        path_item.get = Some(Operation {
1438            parameters: vec![ReferenceOr::Reference {
1439                reference: "#/components/parameters/nonExistent".to_string(),
1440            }],
1441            responses: Responses::default(),
1442            ..Default::default()
1443        });
1444
1445        spec.paths
1446            .paths
1447            .insert("/users".to_string(), ReferenceOr::Item(path_item));
1448
1449        let result = transformer.transform("test", &spec);
1450        assert!(result.is_err());
1451        match result.unwrap_err() {
1452            crate::error::Error::Internal {
1453                kind: crate::error::ErrorKind::Validation,
1454                message: msg,
1455                ..
1456            } => {
1457                assert!(msg.contains("Parameter 'nonExistent' not found in components"));
1458            }
1459            _ => panic!("Expected Validation error"),
1460        }
1461    }
1462
1463    #[test]
1464    fn test_transform_with_nested_parameter_reference() {
1465        let transformer = SpecTransformer::new();
1466        let mut spec = create_test_spec();
1467
1468        let mut components = Components::default();
1469
1470        // Add a parameter that references another parameter
1471        components.parameters.insert(
1472            "userIdRef".to_string(),
1473            ReferenceOr::Reference {
1474                reference: "#/components/parameters/userId".to_string(),
1475            },
1476        );
1477
1478        // Add the actual parameter
1479        let user_id_param = Parameter::Path {
1480            parameter_data: ParameterData {
1481                name: "userId".to_string(),
1482                description: Some("User ID parameter".to_string()),
1483                required: true,
1484                deprecated: Some(false),
1485                format: ParameterSchemaOrContent::Schema(ReferenceOr::Item(Schema {
1486                    schema_data: SchemaData::default(),
1487                    schema_kind: SchemaKind::Type(Type::String(Default::default())),
1488                })),
1489                example: None,
1490                examples: Default::default(),
1491                explode: None,
1492                extensions: Default::default(),
1493            },
1494            style: Default::default(),
1495        };
1496        components
1497            .parameters
1498            .insert("userId".to_string(), ReferenceOr::Item(user_id_param));
1499        spec.components = Some(components);
1500
1501        // Create operation with nested parameter reference
1502        let mut path_item = PathItem::default();
1503        path_item.get = Some(Operation {
1504            parameters: vec![ReferenceOr::Reference {
1505                reference: "#/components/parameters/userIdRef".to_string(),
1506            }],
1507            responses: Responses::default(),
1508            ..Default::default()
1509        });
1510
1511        spec.paths
1512            .paths
1513            .insert("/users/{userId}".to_string(), ReferenceOr::Item(path_item));
1514
1515        let cached = transformer
1516            .transform("test", &spec)
1517            .expect("Transform should succeed with nested parameter reference");
1518
1519        // Verify the nested reference was resolved
1520        assert_eq!(cached.commands.len(), 1);
1521        let command = &cached.commands[0];
1522        assert_eq!(command.parameters.len(), 1);
1523        let param = &command.parameters[0];
1524        assert_eq!(param.name, "userId");
1525        assert_eq!(param.description, Some("User ID parameter".to_string()));
1526    }
1527
1528    #[test]
1529    fn test_transform_with_circular_parameter_reference() {
1530        let transformer = SpecTransformer::new();
1531        let mut spec = create_test_spec();
1532
1533        let mut components = Components::default();
1534
1535        // Create direct circular reference: paramA -> paramA
1536        components.parameters.insert(
1537            "paramA".to_string(),
1538            ReferenceOr::Reference {
1539                reference: "#/components/parameters/paramA".to_string(),
1540            },
1541        );
1542
1543        spec.components = Some(components);
1544
1545        // Create operation with circular parameter reference
1546        let mut path_item = PathItem::default();
1547        path_item.get = Some(Operation {
1548            parameters: vec![ReferenceOr::Reference {
1549                reference: "#/components/parameters/paramA".to_string(),
1550            }],
1551            responses: Responses::default(),
1552            ..Default::default()
1553        });
1554
1555        spec.paths
1556            .paths
1557            .insert("/test".to_string(), ReferenceOr::Item(path_item));
1558
1559        let result = transformer.transform("test", &spec);
1560        assert!(result.is_err());
1561        match result.unwrap_err() {
1562            crate::error::Error::Internal {
1563                kind: crate::error::ErrorKind::Validation,
1564                message: msg,
1565                ..
1566            } => {
1567                assert!(
1568                    msg.contains("Circular reference detected"),
1569                    "Error message should mention circular reference: {msg}"
1570                );
1571            }
1572            _ => panic!("Expected Validation error for circular reference"),
1573        }
1574    }
1575
1576    #[test]
1577    fn test_transform_with_indirect_circular_reference() {
1578        let transformer = SpecTransformer::new();
1579        let mut spec = create_test_spec();
1580
1581        let mut components = Components::default();
1582
1583        // Create indirect circular reference: paramA -> paramB -> paramA
1584        components.parameters.insert(
1585            "paramA".to_string(),
1586            ReferenceOr::Reference {
1587                reference: "#/components/parameters/paramB".to_string(),
1588            },
1589        );
1590
1591        components.parameters.insert(
1592            "paramB".to_string(),
1593            ReferenceOr::Reference {
1594                reference: "#/components/parameters/paramA".to_string(),
1595            },
1596        );
1597
1598        spec.components = Some(components);
1599
1600        // Create operation with circular parameter reference
1601        let mut path_item = PathItem::default();
1602        path_item.get = Some(Operation {
1603            parameters: vec![ReferenceOr::Reference {
1604                reference: "#/components/parameters/paramA".to_string(),
1605            }],
1606            responses: Responses::default(),
1607            ..Default::default()
1608        });
1609
1610        spec.paths
1611            .paths
1612            .insert("/test".to_string(), ReferenceOr::Item(path_item));
1613
1614        let result = transformer.transform("test", &spec);
1615        assert!(result.is_err());
1616        match result.unwrap_err() {
1617            crate::error::Error::Internal {
1618                kind: crate::error::ErrorKind::Validation,
1619                message: msg,
1620                ..
1621            } => {
1622                assert!(
1623                    msg.contains("Circular reference detected") || msg.contains("reference cycle"),
1624                    "Error message should mention circular reference: {msg}"
1625                );
1626            }
1627            _ => panic!("Expected Validation error for circular reference"),
1628        }
1629    }
1630
1631    #[test]
1632    fn test_transform_with_complex_circular_reference() {
1633        let transformer = SpecTransformer::new();
1634        let mut spec = create_test_spec();
1635
1636        let mut components = Components::default();
1637
1638        // Create complex circular reference: paramA -> paramB -> paramC -> paramA
1639        components.parameters.insert(
1640            "paramA".to_string(),
1641            ReferenceOr::Reference {
1642                reference: "#/components/parameters/paramB".to_string(),
1643            },
1644        );
1645
1646        components.parameters.insert(
1647            "paramB".to_string(),
1648            ReferenceOr::Reference {
1649                reference: "#/components/parameters/paramC".to_string(),
1650            },
1651        );
1652
1653        components.parameters.insert(
1654            "paramC".to_string(),
1655            ReferenceOr::Reference {
1656                reference: "#/components/parameters/paramA".to_string(),
1657            },
1658        );
1659
1660        spec.components = Some(components);
1661
1662        // Create operation with circular parameter reference
1663        let mut path_item = PathItem::default();
1664        path_item.get = Some(Operation {
1665            parameters: vec![ReferenceOr::Reference {
1666                reference: "#/components/parameters/paramA".to_string(),
1667            }],
1668            responses: Responses::default(),
1669            ..Default::default()
1670        });
1671
1672        spec.paths
1673            .paths
1674            .insert("/test".to_string(), ReferenceOr::Item(path_item));
1675
1676        let result = transformer.transform("test", &spec);
1677        assert!(result.is_err());
1678        match result.unwrap_err() {
1679            crate::error::Error::Internal {
1680                kind: crate::error::ErrorKind::Validation,
1681                message: msg,
1682                ..
1683            } => {
1684                assert!(
1685                    msg.contains("Circular reference detected") || msg.contains("reference cycle"),
1686                    "Error message should mention circular reference: {msg}"
1687                );
1688            }
1689            _ => panic!("Expected Validation error for circular reference"),
1690        }
1691    }
1692
1693    #[test]
1694    fn test_transform_with_depth_limit() {
1695        let transformer = SpecTransformer::new();
1696        let mut spec = create_test_spec();
1697
1698        let mut components = Components::default();
1699
1700        // Create a chain of references that exceeds MAX_REFERENCE_DEPTH
1701        for i in 0..12 {
1702            let param_name = format!("param{i}");
1703            let next_param = format!("param{}", i + 1);
1704
1705            if i < 11 {
1706                // Reference to next parameter
1707                components.parameters.insert(
1708                    param_name,
1709                    ReferenceOr::Reference {
1710                        reference: format!("#/components/parameters/{next_param}"),
1711                    },
1712                );
1713            } else {
1714                // Last parameter is actual parameter definition
1715                let actual_param = Parameter::Path {
1716                    parameter_data: ParameterData {
1717                        name: "deepParam".to_string(),
1718                        description: Some("Very deeply nested parameter".to_string()),
1719                        required: true,
1720                        deprecated: Some(false),
1721                        format: ParameterSchemaOrContent::Schema(ReferenceOr::Item(Schema {
1722                            schema_data: SchemaData::default(),
1723                            schema_kind: SchemaKind::Type(Type::String(Default::default())),
1724                        })),
1725                        example: None,
1726                        examples: Default::default(),
1727                        explode: None,
1728                        extensions: Default::default(),
1729                    },
1730                    style: Default::default(),
1731                };
1732                components
1733                    .parameters
1734                    .insert(param_name, ReferenceOr::Item(actual_param));
1735            }
1736        }
1737
1738        spec.components = Some(components);
1739
1740        // Create operation with deeply nested parameter reference
1741        let mut path_item = PathItem::default();
1742        path_item.get = Some(Operation {
1743            parameters: vec![ReferenceOr::Reference {
1744                reference: "#/components/parameters/param0".to_string(),
1745            }],
1746            responses: Responses::default(),
1747            ..Default::default()
1748        });
1749
1750        spec.paths
1751            .paths
1752            .insert("/test".to_string(), ReferenceOr::Item(path_item));
1753
1754        let result = transformer.transform("test", &spec);
1755        assert!(result.is_err());
1756        match result.unwrap_err() {
1757            crate::error::Error::Internal {
1758                kind: crate::error::ErrorKind::Validation,
1759                message: msg,
1760                ..
1761            } => {
1762                assert!(
1763                    msg.contains("Maximum reference depth") && msg.contains("10"),
1764                    "Error message should mention depth limit: {msg}"
1765                );
1766            }
1767            _ => panic!("Expected Validation error for depth limit"),
1768        }
1769    }
1770
1771    // ── detect_pagination tests ────────────────────────────────────────────
1772
1773    fn make_operation_with_x_aperture_pagination(value: serde_json::Value) -> Operation {
1774        let mut ext = indexmap::IndexMap::new();
1775        ext.insert(constants::EXT_APERTURE_PAGINATION.to_string(), value);
1776        Operation {
1777            extensions: ext,
1778            responses: Responses::default(),
1779            ..Default::default()
1780        }
1781    }
1782
1783    fn make_spec() -> OpenAPI {
1784        create_test_spec()
1785    }
1786
1787    #[test]
1788    fn test_detect_pagination_explicit_cursor_extension() {
1789        let ext = serde_json::json!({
1790            "strategy": "cursor",
1791            "cursor_field": "next_cursor",
1792            "cursor_param": "after"
1793        });
1794        let op = make_operation_with_x_aperture_pagination(ext);
1795        let spec = make_spec();
1796        let info = SpecTransformer::detect_pagination(&op, &spec);
1797
1798        assert_eq!(info.strategy, PaginationStrategy::Cursor);
1799        assert_eq!(info.cursor_field.as_deref(), Some("next_cursor"));
1800        assert_eq!(info.cursor_param.as_deref(), Some("after"));
1801    }
1802
1803    #[test]
1804    fn test_detect_pagination_explicit_cursor_extension_defaults_cursor_param() {
1805        // When cursor_param is omitted, it defaults to cursor_field
1806        let ext = serde_json::json!({ "strategy": "cursor", "cursor_field": "after" });
1807        let op = make_operation_with_x_aperture_pagination(ext);
1808        let spec = make_spec();
1809        let info = SpecTransformer::detect_pagination(&op, &spec);
1810
1811        assert_eq!(info.strategy, PaginationStrategy::Cursor);
1812        assert_eq!(info.cursor_field.as_deref(), Some("after"));
1813        assert_eq!(info.cursor_param.as_deref(), Some("after"));
1814    }
1815
1816    #[test]
1817    fn test_detect_pagination_explicit_offset_extension() {
1818        let ext = serde_json::json!({
1819            "strategy": "offset",
1820            "page_param": "page",
1821            "limit_param": "limit"
1822        });
1823        let op = make_operation_with_x_aperture_pagination(ext);
1824        let spec = make_spec();
1825        let info = SpecTransformer::detect_pagination(&op, &spec);
1826
1827        assert_eq!(info.strategy, PaginationStrategy::Offset);
1828        assert_eq!(info.page_param.as_deref(), Some("page"));
1829        assert_eq!(info.limit_param.as_deref(), Some("limit"));
1830    }
1831
1832    #[test]
1833    fn test_detect_pagination_explicit_link_header_extension() {
1834        let ext = serde_json::json!({ "strategy": "link-header" });
1835        let op = make_operation_with_x_aperture_pagination(ext);
1836        let spec = make_spec();
1837        let info = SpecTransformer::detect_pagination(&op, &spec);
1838
1839        assert_eq!(info.strategy, PaginationStrategy::LinkHeader);
1840    }
1841
1842    #[test]
1843    fn test_detect_pagination_link_header_in_response() {
1844        use openapiv3::{
1845            Header, HeaderStyle, ParameterSchemaOrContent, Response, SchemaData, SchemaKind,
1846            StringType, Type,
1847        };
1848        let header = Header {
1849            description: None,
1850            style: HeaderStyle::Simple,
1851            required: false,
1852            deprecated: None,
1853            format: ParameterSchemaOrContent::Schema(ReferenceOr::Item(openapiv3::Schema {
1854                schema_data: SchemaData::default(),
1855                schema_kind: SchemaKind::Type(Type::String(StringType::default())),
1856            })),
1857            example: None,
1858            examples: Default::default(),
1859            extensions: Default::default(),
1860        };
1861        let mut response = Response::default();
1862        response
1863            .headers
1864            .insert("Link".to_string(), ReferenceOr::Item(header));
1865
1866        let mut responses = Responses::default();
1867        responses.responses.insert(
1868            openapiv3::StatusCode::Code(200),
1869            ReferenceOr::Item(response),
1870        );
1871
1872        let op = Operation {
1873            responses,
1874            ..Default::default()
1875        };
1876        let spec = make_spec();
1877        let info = SpecTransformer::detect_pagination(&op, &spec);
1878
1879        assert_eq!(info.strategy, PaginationStrategy::LinkHeader);
1880    }
1881
1882    fn make_string_schema_param(name: &str) -> openapiv3::Parameter {
1883        use openapiv3::{
1884            Parameter, ParameterData, ParameterSchemaOrContent, SchemaData, SchemaKind, StringType,
1885            Type,
1886        };
1887        Parameter::Query {
1888            parameter_data: ParameterData {
1889                name: name.to_string(),
1890                description: None,
1891                required: false,
1892                deprecated: None,
1893                format: ParameterSchemaOrContent::Schema(ReferenceOr::Item(openapiv3::Schema {
1894                    schema_data: SchemaData::default(),
1895                    schema_kind: SchemaKind::Type(Type::String(StringType::default())),
1896                })),
1897                example: None,
1898                examples: Default::default(),
1899                explode: None,
1900                extensions: Default::default(),
1901            },
1902            allow_reserved: false,
1903            style: Default::default(),
1904            allow_empty_value: None,
1905        }
1906    }
1907
1908    #[test]
1909    fn test_detect_pagination_offset_heuristic_page_param() {
1910        let op = Operation {
1911            parameters: vec![
1912                ReferenceOr::Item(make_string_schema_param("page")),
1913                ReferenceOr::Item(make_string_schema_param("limit")),
1914            ],
1915            responses: Responses::default(),
1916            ..Default::default()
1917        };
1918        let spec = make_spec();
1919        let info = SpecTransformer::detect_pagination(&op, &spec);
1920
1921        assert_eq!(info.strategy, PaginationStrategy::Offset);
1922        assert_eq!(info.page_param.as_deref(), Some("page"));
1923        assert_eq!(info.limit_param.as_deref(), Some("limit"));
1924    }
1925
1926    #[test]
1927    fn test_detect_pagination_no_strategy() {
1928        let op = Operation {
1929            responses: Responses::default(),
1930            ..Default::default()
1931        };
1932        let spec = make_spec();
1933        let info = SpecTransformer::detect_pagination(&op, &spec);
1934
1935        assert_eq!(info.strategy, PaginationStrategy::None);
1936    }
1937}