aperture_cli/spec/
parser.rs1use crate::constants;
2use crate::error::Error;
3use openapiv3::OpenAPI;
4use regex::Regex;
5
6fn preprocess_for_compatibility(content: &str) -> String {
13 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 let is_json = content.trim_start().starts_with('{');
31 let mut result = content.to_string();
32
33 if is_json {
35 return fix_json_boolean_values(result, BOOLEAN_PROPERTIES);
36 }
37
38 result = fix_yaml_boolean_values(result, BOOLEAN_PROPERTIES);
40
41 if result.contains('"') {
43 result = fix_json_boolean_values(result, BOOLEAN_PROPERTIES);
44 }
45
46 result
47}
48
49fn 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
67fn 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
83fn fix_component_indentation(content: &str) -> String {
86 let mut result = content.to_string();
87
88 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 result = result.replace(&format!("\n {section}:"), &format!("\n {section}:"));
105 }
106
107 result
108}
109
110pub fn parse_openapi(content: &str) -> Result<OpenAPI, Error> {
139 let mut preprocessed = preprocess_for_compatibility(content);
141
142 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 preprocessed = fix_component_indentation(&preprocessed);
152
153 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), #[cfg(feature = "openapi31")]
159 Err(_) => {} }
161 }
162
163 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
173fn parse_json_with_fallback(content: &str) -> Result<OpenAPI, Error> {
175 match serde_json::from_str::<OpenAPI>(content) {
177 Ok(spec) => Ok(spec),
178 Err(json_err) => {
179 if let Ok(spec) = serde_yaml::from_str::<OpenAPI>(content) {
181 return Ok(spec);
182 }
183
184 Err(Error::serialization_error(format!(
186 "Failed to parse OpenAPI spec as JSON: {json_err}"
187 )))
188 }
189 }
190}
191
192fn parse_yaml_with_fallback(content: &str) -> Result<OpenAPI, Error> {
194 match serde_yaml::from_str::<OpenAPI>(content) {
196 Ok(spec) => Ok(spec),
197 Err(yaml_err) => {
198 if let Ok(spec) = serde_json::from_str::<OpenAPI>(content) {
200 return Ok(spec);
201 }
202
203 Err(Error::Yaml(yaml_err))
205 }
206 }
207}
208
209#[cfg(feature = "openapi31")]
211fn parse_with_oas3_direct_with_original(
212 preprocessed: &str,
213 original: &str,
214) -> Result<OpenAPI, Error> {
215 let security_schemes_from_yaml = extract_security_schemes_from_yaml(original);
217
218 let oas3_spec = match oas3::from_yaml(preprocessed) {
221 Ok(spec) => spec,
222 Err(_yaml_err) => {
223 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 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 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 restore_security_schemes(&mut spec, security_schemes_from_yaml);
255
256 Ok(spec)
257}
258
259#[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 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#[cfg(feature = "openapi31")]
288fn extract_security_schemes_from_yaml(
289 content: &str,
290) -> Option<indexmap::IndexMap<String, openapiv3::ReferenceOr<openapiv3::SecurityScheme>>> {
291 let value = parse_content_as_value(content)?;
293
294 let security_schemes = value.get("components")?.get("securitySchemes")?;
296
297 serde_yaml::from_value(security_schemes.clone()).ok()
299}
300
301#[cfg(feature = "openapi31")]
303fn parse_content_as_value(content: &str) -> Option<serde_yaml::Value> {
304 if let Ok(value) = serde_yaml::from_str::<serde_yaml::Value>(content) {
306 return Some(value);
307 }
308
309 serde_json::from_str::<serde_json::Value>(content)
311 .ok()
312 .and_then(|json| serde_yaml::to_value(json).ok())
313}
314
315#[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 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 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 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 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 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"#)); }
437
438 #[test]
439 fn test_preprocess_preserves_multi_digit_numbers() {
440 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 assert!(result.contains("minimum: 10"));
457 assert!(result.contains("maximum: 100"));
458 assert!(result.contains("exclusiveMinimum: 18"));
459 assert!(!result.contains("true0"));
461 assert!(!result.contains("true8"));
462 assert!(!result.contains("true00"));
463 assert!(!result.contains("false0"));
464 }
465}