Skip to main content

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