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 =
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
85fn fix_component_indentation(content: &str) -> String {
88 let mut result = content.to_string();
89
90 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 result = result.replace(&format!("\n {section}:"), &format!("\n {section}:"));
107 }
108
109 result
110}
111
112pub fn parse_openapi(content: &str) -> Result<OpenAPI, Error> {
141 let mut preprocessed = preprocess_for_compatibility(content);
143
144 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 preprocessed = fix_component_indentation(&preprocessed);
154
155 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), #[cfg(feature = "openapi31")]
161 Err(_) => {} }
163 }
164
165 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
175fn parse_json_with_fallback(content: &str) -> Result<OpenAPI, Error> {
177 match serde_json::from_str::<OpenAPI>(content) {
179 Ok(spec) => Ok(spec),
180 Err(json_err) => {
181 if let Ok(spec) = serde_yaml::from_str::<OpenAPI>(content) {
183 return Ok(spec);
184 }
185
186 Err(Error::serialization_error(format!(
188 "Failed to parse OpenAPI spec as JSON: {json_err}"
189 )))
190 }
191 }
192}
193
194fn parse_yaml_with_fallback(content: &str) -> Result<OpenAPI, Error> {
196 match serde_yaml::from_str::<OpenAPI>(content) {
198 Ok(spec) => Ok(spec),
199 Err(yaml_err) => {
200 if let Ok(spec) = serde_json::from_str::<OpenAPI>(content) {
202 return Ok(spec);
203 }
204
205 Err(Error::Yaml(yaml_err))
207 }
208 }
209}
210
211#[cfg(feature = "openapi31")]
213fn parse_with_oas3_direct_with_original(
214 preprocessed: &str,
215 original: &str,
216) -> Result<OpenAPI, Error> {
217 let security_schemes_from_yaml = extract_security_schemes_from_yaml(original);
219
220 let oas3_spec = match oas3::from_yaml(preprocessed) {
223 Ok(spec) => spec,
224 Err(_yaml_err) => {
225 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 eprintln!(
236 "{} OpenAPI 3.1 specification detected. Using compatibility mode.",
237 crate::constants::MSG_WARNING_PREFIX
238 );
239 eprintln!(" Some 3.1-specific features may not be available.");
241
242 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 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 restore_security_schemes(&mut spec, security_schemes_from_yaml);
259
260 Ok(spec)
261}
262
263#[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 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#[cfg(feature = "openapi31")]
292fn extract_security_schemes_from_yaml(
293 content: &str,
294) -> Option<indexmap::IndexMap<String, openapiv3::ReferenceOr<openapiv3::SecurityScheme>>> {
295 let value = parse_content_as_value(content)?;
297
298 let security_schemes = value.get("components")?.get("securitySchemes")?;
300
301 serde_yaml::from_value(security_schemes.clone()).ok()
303}
304
305#[cfg(feature = "openapi31")]
307fn parse_content_as_value(content: &str) -> Option<serde_yaml::Value> {
308 if let Ok(value) = serde_yaml::from_str::<serde_yaml::Value>(content) {
310 return Some(value);
311 }
312
313 serde_json::from_str::<serde_json::Value>(content)
315 .ok()
316 .and_then(|json| serde_yaml::to_value(json).ok())
317}
318
319#[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 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 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 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 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 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"#)); }
441
442 #[test]
443 fn test_preprocess_preserves_multi_digit_numbers() {
444 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 assert!(result.contains("minimum: 10"));
461 assert!(result.contains("maximum: 100"));
462 assert!(result.contains("exclusiveMinimum: 18"));
463 assert!(!result.contains("true0"));
465 assert!(!result.contains("true8"));
466 assert!(!result.contains("true00"));
467 assert!(!result.contains("false0"));
468 }
469}