aperture_cli/spec/
parser.rs

1use crate::constants;
2use crate::error::Error;
3use openapiv3::OpenAPI;
4use regex::Regex;
5
6/// Preprocesses `OpenAPI` content to fix common compatibility issues
7///
8/// This function handles:
9/// - Converting numeric boolean values (0/1) to proper booleans (false/true)
10/// - Works with both YAML and JSON formats
11/// - Preserves multi-digit numbers (e.g., 10, 18, 100)
12fn preprocess_for_compatibility(content: &str) -> String {
13    // Properties that should be boolean in OpenAPI 3.0 but sometimes use 0/1
14    // Note: exclusiveMinimum/Maximum are boolean in 3.0 but numeric in 3.1
15    const BOOLEAN_PROPERTIES: &[&str] = &[
16        constants::FIELD_DEPRECATED,
17        constants::FIELD_REQUIRED,
18        constants::FIELD_READ_ONLY,
19        constants::FIELD_WRITE_ONLY,
20        constants::FIELD_NULLABLE,
21        constants::FIELD_UNIQUE_ITEMS,
22        constants::FIELD_ALLOW_EMPTY_VALUE,
23        constants::FIELD_EXPLODE,
24        constants::FIELD_ALLOW_RESERVED,
25        constants::FIELD_EXCLUSIVE_MINIMUM,
26        constants::FIELD_EXCLUSIVE_MAXIMUM,
27    ];
28
29    // Detect format to optimize processing
30    let is_json = content.trim_start().starts_with('{');
31    let mut result = content.to_string();
32
33    // Apply appropriate replacements based on format
34    if is_json {
35        return fix_json_boolean_values(result, BOOLEAN_PROPERTIES);
36    }
37
38    // Process as YAML
39    result = fix_yaml_boolean_values(result, BOOLEAN_PROPERTIES);
40
41    // JSON might be embedded in YAML comments or examples, so also check JSON patterns
42    if result.contains('"') {
43        result = fix_json_boolean_values(result, BOOLEAN_PROPERTIES);
44    }
45
46    result
47}
48
49/// Fix boolean values in YAML format
50fn fix_yaml_boolean_values(mut content: String, properties: &[&str]) -> String {
51    for property in properties {
52        let pattern_0 = Regex::new(&format!(r"\b{property}: 0\b"))
53            .expect("Regex pattern is hardcoded and valid");
54        let pattern_1 = Regex::new(&format!(r"\b{property}: 1\b"))
55            .expect("Regex pattern is hardcoded and valid");
56
57        content = pattern_0
58            .replace_all(&content, &format!("{property}: false"))
59            .to_string();
60        content = pattern_1
61            .replace_all(&content, &format!("{property}: true"))
62            .to_string();
63    }
64    content
65}
66
67/// Fix boolean values in JSON format
68fn fix_json_boolean_values(mut content: String, properties: &[&str]) -> String {
69    for property in properties {
70        let pattern_0 = Regex::new(&format!(r#""{property}"\s*:\s*0\b"#)).unwrap();
71        let pattern_1 = Regex::new(&format!(r#""{property}"\s*:\s*1\b"#)).unwrap();
72
73        content = pattern_0
74            .replace_all(&content, &format!(r#""{property}":false"#))
75            .to_string();
76        content = pattern_1
77            .replace_all(&content, &format!(r#""{property}":true"#))
78            .to_string();
79    }
80    content
81}
82
83/// Fixes common indentation issues in components section for malformed specs
84/// This is only applied to `OpenAPI` 3.1 specs where we've seen such issues
85fn fix_component_indentation(content: &str) -> String {
86    let mut result = content.to_string();
87
88    // Some 3.1 specs (like OpenProject) have component subsections at 2 spaces instead of 4
89    // Only fix these specific sections when they appear at the wrong indentation level
90    let component_sections = [
91        constants::COMPONENT_SCHEMAS,
92        constants::COMPONENT_RESPONSES,
93        constants::COMPONENT_EXAMPLES,
94        constants::COMPONENT_PARAMETERS,
95        constants::COMPONENT_REQUEST_BODIES,
96        constants::COMPONENT_HEADERS,
97        constants::COMPONENT_SECURITY_SCHEMES,
98        constants::COMPONENT_LINKS,
99        constants::COMPONENT_CALLBACKS,
100    ];
101
102    for section in &component_sections {
103        // Only replace if it's at 2-space indentation (wrong for components subsections)
104        result = result.replace(&format!("\n  {section}:"), &format!("\n    {section}:"));
105    }
106
107    result
108}
109
110/// Parses `OpenAPI` content, supporting both 3.0.x (directly) and 3.1.x (via oas3 fallback).
111///
112/// This function first attempts to parse the content as `OpenAPI` 3.0.x using the `openapiv3` crate.
113/// If that fails, it falls back to parsing as `OpenAPI` 3.1.x using the `oas3` crate, then attempts
114/// to convert the result to `OpenAPI` 3.0.x format.
115///
116/// # Arguments
117///
118/// * `content` - The YAML or JSON content of an `OpenAPI` specification
119///
120/// # Returns
121///
122/// An `OpenAPI` 3.0.x structure, or an error if parsing fails
123///
124/// # Errors
125///
126/// Returns an error if:
127/// - The content is not valid YAML
128/// - The content is not a valid `OpenAPI` specification
129/// - `OpenAPI` 3.1 features cannot be converted to 3.0 format
130///
131/// # Limitations
132///
133/// When parsing `OpenAPI` 3.1.x specifications:
134/// - Some 3.1-specific features may be lost or downgraded
135/// - Type arrays become single types
136/// - Webhooks are not supported
137/// - JSON Schema 2020-12 features may not be preserved
138pub fn parse_openapi(content: &str) -> Result<OpenAPI, Error> {
139    // Always preprocess for compatibility issues
140    let mut preprocessed = preprocess_for_compatibility(content);
141
142    // Check if this looks like OpenAPI 3.1.x (both YAML and JSON formats)
143    if content.contains("openapi: 3.1")
144        || content.contains("openapi: \"3.1")
145        || content.contains("openapi: '3.1")
146        || content.contains(r#""openapi":"3.1"#)
147        || content.contains(r#""openapi": "3.1"#)
148    {
149        // For OpenAPI 3.1 specs, also fix potential indentation issues
150        // (some 3.1 specs like OpenProject have malformed indentation)
151        preprocessed = fix_component_indentation(&preprocessed);
152
153        // Try oas3 first for 3.1 specs - pass original content for security scheme extraction
154        match parse_with_oas3_direct_with_original(&preprocessed, content) {
155            Ok(spec) => return Ok(spec),
156            #[cfg(not(feature = "openapi31"))]
157            Err(e) => return Err(e), // Return the "not enabled" error immediately
158            #[cfg(feature = "openapi31")]
159            Err(_) => {} // Fall through to try regular parsing
160        }
161    }
162
163    // Try parsing as OpenAPI 3.0.x (most common case)
164    // Detect format based on content structure
165    let trimmed = content.trim();
166    if trimmed.starts_with('{') {
167        parse_json_with_fallback(&preprocessed)
168    } else {
169        parse_yaml_with_fallback(&preprocessed)
170    }
171}
172
173/// Parse JSON content with YAML fallback
174fn parse_json_with_fallback(content: &str) -> Result<OpenAPI, Error> {
175    // Try JSON first since content looks like JSON
176    match serde_json::from_str::<OpenAPI>(content) {
177        Ok(spec) => Ok(spec),
178        Err(json_err) => {
179            // Try YAML as fallback
180            if let Ok(spec) = serde_yaml::from_str::<OpenAPI>(content) {
181                return Ok(spec);
182            }
183
184            // Return JSON error since content looked like JSON
185            Err(Error::serialization_error(format!(
186                "Failed to parse OpenAPI spec as JSON: {json_err}"
187            )))
188        }
189    }
190}
191
192/// Parse YAML content with JSON fallback
193fn parse_yaml_with_fallback(content: &str) -> Result<OpenAPI, Error> {
194    // Try YAML first since content looks like YAML
195    match serde_yaml::from_str::<OpenAPI>(content) {
196        Ok(spec) => Ok(spec),
197        Err(yaml_err) => {
198            // Try JSON as fallback
199            if let Ok(spec) = serde_json::from_str::<OpenAPI>(content) {
200                return Ok(spec);
201            }
202
203            // Return YAML error since content looked like YAML
204            Err(Error::Yaml(yaml_err))
205        }
206    }
207}
208
209/// Direct parsing with oas3 for known 3.1 specs with original content for security scheme extraction
210#[cfg(feature = "openapi31")]
211fn parse_with_oas3_direct_with_original(
212    preprocessed: &str,
213    original: &str,
214) -> Result<OpenAPI, Error> {
215    // First, extract security schemes from the original content before any conversions
216    let security_schemes_from_yaml = extract_security_schemes_from_yaml(original);
217
218    // Try parsing with oas3 (supports OpenAPI 3.1.x) using preprocessed content
219    // First try as YAML, then as JSON if YAML fails
220    let oas3_spec = match oas3::from_yaml(preprocessed) {
221        Ok(spec) => spec,
222        Err(_yaml_err) => {
223            // Try parsing as JSON
224            oas3::from_json(preprocessed).map_err(|e| {
225                Error::serialization_error(format!(
226                    "Failed to parse OpenAPI 3.1 spec as YAML or JSON: {e}"
227                ))
228            })?;
229        }
230    };
231
232    eprintln!(
233        "{} OpenAPI 3.1 specification detected. Using compatibility mode.",
234        crate::constants::MSG_WARNING_PREFIX
235    );
236    eprintln!("         Some 3.1-specific features may not be available.");
237
238    // Convert oas3 spec to JSON, then attempt to parse as openapiv3
239    let json = oas3::to_json(&oas3_spec).map_err(|e| {
240        Error::serialization_error(format!("Failed to serialize OpenAPI 3.1 spec: {e}"))
241    })?;
242
243    // Parse the JSON as OpenAPI 3.0.x
244    // This may fail if there are incompatible 3.1 features
245    let mut spec = serde_json::from_str::<OpenAPI>(&json).map_err(|e| {
246        Error::validation_error(format!(
247            "OpenAPI 3.1 spec contains features incompatible with 3.0: {e}. \
248            Consider converting the spec to OpenAPI 3.0 format."
249        ))
250    })?;
251
252    // WORKAROUND: The oas3 conversion loses security schemes, so restore them
253    // from the original content that we extracted earlier
254    restore_security_schemes(&mut spec, security_schemes_from_yaml);
255
256    Ok(spec)
257}
258
259/// Restore security schemes to the OpenAPI spec if they were lost during conversion
260#[cfg(feature = "openapi31")]
261fn restore_security_schemes(
262    spec: &mut OpenAPI,
263    security_schemes: Option<
264        indexmap::IndexMap<String, openapiv3::ReferenceOr<openapiv3::SecurityScheme>>,
265    >,
266) {
267    if let Some(schemes) = security_schemes {
268        // Ensure components exists and add the security schemes
269        match spec.components {
270            Some(ref mut components) => {
271                components.security_schemes = schemes;
272            }
273            None => {
274                let mut components = openapiv3::Components::default();
275                components.security_schemes = schemes;
276                spec.components = Some(components);
277            }
278        }
279    }
280}
281
282/// Extract security schemes from YAML/JSON content before any processing
283///
284/// This function is needed because the oas3 library's conversion from OpenAPI 3.1 to 3.0
285/// sometimes loses security scheme definitions. We extract them from the original content
286/// to restore them after conversion.
287#[cfg(feature = "openapi31")]
288fn extract_security_schemes_from_yaml(
289    content: &str,
290) -> Option<indexmap::IndexMap<String, openapiv3::ReferenceOr<openapiv3::SecurityScheme>>> {
291    // Parse content as either YAML or JSON
292    let value = parse_content_as_value(content)?;
293
294    // Navigate to components.securitySchemes
295    let security_schemes = value.get("components")?.get("securitySchemes")?;
296
297    // Convert to the expected type
298    serde_yaml::from_value(security_schemes.clone()).ok()
299}
300
301/// Parse content as either YAML or JSON into a generic Value type
302#[cfg(feature = "openapi31")]
303fn parse_content_as_value(content: &str) -> Option<serde_yaml::Value> {
304    // Try YAML first (more common for OpenAPI specs)
305    if let Ok(value) = serde_yaml::from_str::<serde_yaml::Value>(content) {
306        return Some(value);
307    }
308
309    // Fallback to JSON
310    serde_json::from_str::<serde_json::Value>(content)
311        .ok()
312        .and_then(|json| serde_yaml::to_value(json).ok())
313}
314
315/// Fallback for when `OpenAPI` 3.1 support is not compiled in
316#[cfg(not(feature = "openapi31"))]
317fn parse_with_oas3_direct_with_original(
318    _preprocessed: &str,
319    _original: &str,
320) -> Result<OpenAPI, Error> {
321    Err(Error::validation_error(
322        "OpenAPI 3.1 support is not enabled. Rebuild with --features openapi31 to enable 3.1 support."
323    ))
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329
330    #[test]
331    fn test_parse_openapi_30() {
332        let spec_30 = r#"
333openapi: 3.0.0
334info:
335  title: Test API
336  version: 1.0.0
337paths: {}
338"#;
339
340        let result = parse_openapi(spec_30);
341        assert!(result.is_ok());
342        let spec = result.unwrap();
343        assert_eq!(spec.openapi, "3.0.0");
344    }
345
346    #[test]
347    fn test_parse_openapi_31() {
348        let spec_31 = r#"
349openapi: 3.1.0
350info:
351  title: Test API
352  version: 1.0.0
353paths: {}
354"#;
355
356        let result = parse_openapi(spec_31);
357
358        #[cfg(feature = "openapi31")]
359        {
360            // With the feature, it should parse successfully
361            assert!(result.is_ok());
362            if let Ok(spec) = result {
363                assert!(spec.openapi.starts_with("3."));
364            }
365        }
366
367        #[cfg(not(feature = "openapi31"))]
368        {
369            // Without the feature, it should return an error about missing support
370            assert!(result.is_err());
371            if let Err(Error::Internal {
372                kind: crate::error::ErrorKind::Validation,
373                message,
374                ..
375            }) = result
376            {
377                assert!(message.contains("OpenAPI 3.1 support is not enabled"));
378            } else {
379                panic!("Expected validation error about missing 3.1 support");
380            }
381        }
382    }
383
384    #[test]
385    fn test_parse_invalid_yaml() {
386        let invalid_yaml = "not: valid: yaml: at: all:";
387
388        let result = parse_openapi(invalid_yaml);
389        assert!(result.is_err());
390    }
391
392    #[test]
393    fn test_preprocess_boolean_values() {
394        // Test that 0/1 are converted to false/true
395        let input = r#"
396deprecated: 0
397required: 1
398readOnly: 0
399writeOnly: 1
400"#;
401        let result = preprocess_for_compatibility(input);
402        assert!(result.contains("deprecated: false"));
403        assert!(result.contains("required: true"));
404        assert!(result.contains("readOnly: false"));
405        assert!(result.contains("writeOnly: true"));
406    }
407
408    #[test]
409    fn test_preprocess_exclusive_min_max() {
410        // Test that exclusiveMinimum/Maximum 0/1 are converted but other numbers are preserved
411        let input = r#"
412exclusiveMinimum: 0
413exclusiveMaximum: 1
414exclusiveMinimum: 10
415exclusiveMaximum: 18
416exclusiveMinimum: 100
417"#;
418        let result = preprocess_for_compatibility(input);
419        assert!(result.contains("exclusiveMinimum: false"));
420        assert!(result.contains("exclusiveMaximum: true"));
421        assert!(result.contains("exclusiveMinimum: 10"));
422        assert!(result.contains("exclusiveMaximum: 18"));
423        assert!(result.contains("exclusiveMinimum: 100"));
424    }
425
426    #[test]
427    fn test_preprocess_json_format() {
428        // Test that JSON format boolean values are converted
429        let input = r#"{"deprecated":0,"required":1,"exclusiveMinimum":0,"exclusiveMaximum":1,"otherValue":10}"#;
430        let result = preprocess_for_compatibility(input);
431        assert!(result.contains(r#""deprecated":false"#));
432        assert!(result.contains(r#""required":true"#));
433        assert!(result.contains(r#""exclusiveMinimum":false"#));
434        assert!(result.contains(r#""exclusiveMaximum":true"#));
435        assert!(result.contains(r#""otherValue":10"#)); // Should not be changed
436    }
437
438    #[test]
439    fn test_preprocess_preserves_multi_digit_numbers() {
440        // Test that numbers like 10, 18, 100 are not corrupted
441        let input = r#"
442paths:
443  /test:
444    get:
445      parameters:
446        - name: test
447          in: query
448          schema:
449            type: integer
450            minimum: 10
451            maximum: 100
452            exclusiveMinimum: 18
453"#;
454        let result = preprocess_for_compatibility(input);
455        // These should remain unchanged
456        assert!(result.contains("minimum: 10"));
457        assert!(result.contains("maximum: 100"));
458        assert!(result.contains("exclusiveMinimum: 18"));
459        // Should not contain corrupted values
460        assert!(!result.contains("true0"));
461        assert!(!result.contains("true8"));
462        assert!(!result.contains("true00"));
463        assert!(!result.contains("false0"));
464    }
465}