1use crate::{Error, Result};
10use serde_json::Value;
11use std::path::Path;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum SpecFormat {
16 OpenApi20,
18 OpenApi30,
20 OpenApi31,
22 GraphQL,
24 Protobuf,
26}
27
28impl SpecFormat {
29 pub fn detect(content: &str, file_path: Option<&Path>) -> Result<Self> {
31 if let Some(path) = file_path {
33 if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
34 match ext.to_lowercase().as_str() {
35 "graphql" | "gql" => return Ok(Self::GraphQL),
36 "proto" => return Ok(Self::Protobuf),
37 _ => {}
38 }
39 }
40 }
41
42 let is_likely_json = |s: &str| {
44 let trimmed = s.trim();
45 trimmed.starts_with('{') || trimmed.starts_with('[')
46 };
47
48 let is_likely_yaml = |s: &str| {
50 let trimmed = s.trim();
51 !is_likely_json(s)
52 && (trimmed.contains(":\n")
53 || trimmed.contains(": ")
54 || trimmed.starts_with('#')
55 || trimmed.contains('\n'))
56 };
57
58 if is_likely_json(content) {
60 if let Ok(json) = serde_json::from_str::<Value>(content) {
61 if json.get("swagger").is_some() {
63 if let Some(swagger_version) = json.get("swagger").and_then(|v| v.as_str()) {
64 if swagger_version.starts_with("2.") {
65 return Ok(Self::OpenApi20);
66 }
67 }
68 }
69
70 if json.get("openapi").is_some() {
71 if let Some(openapi_version) = json.get("openapi").and_then(|v| v.as_str()) {
72 if openapi_version.starts_with("3.0") {
73 return Ok(Self::OpenApi30);
74 } else if openapi_version.starts_with("3.1") {
75 return Ok(Self::OpenApi31);
76 }
77 }
78 }
79 }
80 }
81
82 if is_likely_yaml(content) || !is_likely_json(content) {
84 if let Ok(yaml) = serde_yaml::from_str::<Value>(content) {
85 if yaml.get("swagger").is_some() {
86 return Ok(Self::OpenApi20);
87 }
88 if yaml.get("openapi").is_some() {
89 if let Some(openapi_version) = yaml.get("openapi").and_then(|v| v.as_str()) {
90 if openapi_version.starts_with("3.0") {
91 return Ok(Self::OpenApi30);
92 } else if openapi_version.starts_with("3.1") {
93 return Ok(Self::OpenApi31);
94 }
95 }
96 }
97 }
98 }
99
100 let content_lower = content.trim().to_lowercase();
102 if content_lower.contains("type ")
103 && (content_lower.contains("query") || content_lower.contains("mutation"))
104 {
105 return Ok(Self::GraphQL);
106 }
107
108 Err(Error::validation(format!(
111 "Could not detect specification format. \
112 Expected OpenAPI (2.0/3.x), GraphQL schema, or protobuf definition."
113 )))
114 }
115
116 pub fn display_name(&self) -> &'static str {
118 match self {
119 Self::OpenApi20 => "OpenAPI 2.0 (Swagger)",
120 Self::OpenApi30 => "OpenAPI 3.0.x",
121 Self::OpenApi31 => "OpenAPI 3.1.x",
122 Self::GraphQL => "GraphQL Schema",
123 Self::Protobuf => "Protocol Buffers",
124 }
125 }
126}
127
128#[derive(Debug, Clone)]
130pub struct ValidationResult {
131 pub is_valid: bool,
133 pub errors: Vec<ValidationError>,
135 pub warnings: Vec<String>,
137}
138
139impl ValidationResult {
140 pub fn success() -> Self {
142 Self {
143 is_valid: true,
144 errors: vec![],
145 warnings: vec![],
146 }
147 }
148
149 pub fn failure(errors: Vec<ValidationError>) -> Self {
151 Self {
152 is_valid: false,
153 errors,
154 warnings: vec![],
155 }
156 }
157
158 pub fn with_warning(mut self, warning: String) -> Self {
160 self.warnings.push(warning);
161 self
162 }
163
164 pub fn with_warnings(mut self, warnings: Vec<String>) -> Self {
166 self.warnings.extend(warnings);
167 self
168 }
169
170 pub fn has_errors(&self) -> bool {
172 !self.errors.is_empty()
173 }
174}
175
176#[derive(Debug, Clone)]
178pub struct ValidationError {
179 pub message: String,
181 pub path: Option<String>,
183 pub code: Option<String>,
185 pub suggestion: Option<String>,
187}
188
189impl ValidationError {
190 pub fn new(message: String) -> Self {
192 Self {
193 message,
194 path: None,
195 code: None,
196 suggestion: None,
197 }
198 }
199
200 pub fn at_path(mut self, path: String) -> Self {
202 self.path = Some(path);
203 self
204 }
205
206 pub fn with_code(mut self, code: String) -> Self {
208 self.code = Some(code);
209 self
210 }
211
212 pub fn with_suggestion(mut self, suggestion: String) -> Self {
214 self.suggestion = Some(suggestion);
215 self
216 }
217}
218
219impl std::fmt::Display for ValidationError {
220 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
221 write!(f, "{}", self.message)?;
222 if let Some(path) = &self.path {
223 write!(f, " (at {})", path)?;
224 }
225 if let Some(suggestion) = &self.suggestion {
226 write!(f, ". Suggestion: {}", suggestion)?;
227 }
228 Ok(())
229 }
230}
231
232pub struct OpenApiValidator;
234
235impl OpenApiValidator {
236 pub fn validate(spec: &Value, format: SpecFormat) -> ValidationResult {
238 let mut errors = Vec::new();
239
240 if !spec.is_object() {
242 return ValidationResult::failure(vec![ValidationError::new(
243 "OpenAPI specification must be a JSON object".to_string(),
244 )
245 .with_code("INVALID_ROOT".to_string())]);
246 }
247
248 match format {
250 SpecFormat::OpenApi20 => {
251 Self::validate_version_field(
252 spec,
253 "swagger",
254 &mut errors,
255 "/swagger",
256 "OpenAPI 2.0",
257 );
258 Self::validate_common_sections(spec, &mut errors, "OpenAPI 2.0");
259 }
260 SpecFormat::OpenApi30 | SpecFormat::OpenApi31 => {
261 Self::validate_version_field(
262 spec,
263 "openapi",
264 &mut errors,
265 "/openapi",
266 "OpenAPI 3.x",
267 );
268 if let Some(version) = spec.get("openapi").and_then(|v| v.as_str()) {
269 if !version.starts_with("3.") {
270 errors.push(
271 ValidationError::new(format!(
272 "Invalid OpenAPI version '{}'. Expected 3.0.x or 3.1.x",
273 version
274 ))
275 .at_path("/openapi".to_string())
276 .with_code("INVALID_VERSION".to_string())
277 .with_suggestion(
278 "Use 'openapi': '3.0.0' or 'openapi': '3.1.0'".to_string(),
279 ),
280 );
281 }
282 }
283 Self::validate_common_sections(spec, &mut errors, "OpenAPI 3.x");
284 }
285 _ => {
286 errors.push(ValidationError::new(
287 "Invalid format for OpenAPI validation".to_string(),
288 ));
289 }
290 }
291
292 if errors.is_empty() {
293 ValidationResult::success()
294 } else {
295 ValidationResult::failure(errors)
296 }
297 }
298
299 fn validate_version_field(
301 spec: &Value,
302 field_name: &str,
303 errors: &mut Vec<ValidationError>,
304 path: &str,
305 spec_type: &str,
306 ) {
307 let _version = spec.get(field_name).and_then(|v| v.as_str()).ok_or_else(|| {
308 errors.push(
309 ValidationError::new(format!(
310 "Missing '{}' field in {} spec",
311 field_name, spec_type
312 ))
313 .at_path(path.to_string())
314 .with_code(format!("MISSING_{}_FIELD", field_name.to_uppercase()))
315 .with_suggestion(format!(
316 "Add '{}': '{}' to the root of the specification",
317 field_name,
318 if field_name == "swagger" {
319 "2.0"
320 } else {
321 "3.0.0 or 3.1.0"
322 }
323 )),
324 );
325 });
326 }
327
328 fn validate_common_sections(spec: &Value, errors: &mut Vec<ValidationError>, spec_type: &str) {
330 let info = spec.get("info").ok_or_else(|| {
332 errors.push(
333 ValidationError::new(format!("Missing 'info' section in {} spec", spec_type))
334 .at_path("/info".to_string())
335 .with_code("MISSING_INFO".to_string())
336 .with_suggestion(
337 "Add an 'info' section with 'title' and 'version' fields".to_string(),
338 ),
339 );
340 });
341
342 if let Ok(info) = info {
343 if info.get("title").is_none()
345 || info.get("title").and_then(|t| t.as_str()).map(|s| s.is_empty()) == Some(true)
346 {
347 errors.push(
348 ValidationError::new("Missing or empty 'info.title' field".to_string())
349 .at_path("/info/title".to_string())
350 .with_code("MISSING_TITLE".to_string())
351 .with_suggestion("Add 'title' field to the 'info' section".to_string()),
352 );
353 }
354
355 if info.get("version").is_none()
357 || info.get("version").and_then(|v| v.as_str()).map(|s| s.is_empty()) == Some(true)
358 {
359 errors.push(
360 ValidationError::new("Missing or empty 'info.version' field".to_string())
361 .at_path("/info/version".to_string())
362 .with_code("MISSING_VERSION".to_string())
363 .with_suggestion("Add 'version' field to the 'info' section".to_string()),
364 );
365 }
366 }
367
368 let paths = spec.get("paths").ok_or_else(|| {
370 errors.push(
371 ValidationError::new(format!(
372 "Missing 'paths' section in {} spec. At least one endpoint is required.",
373 spec_type
374 ))
375 .at_path("/paths".to_string())
376 .with_code("MISSING_PATHS".to_string())
377 .with_suggestion(
378 "Add a 'paths' section with at least one endpoint definition".to_string(),
379 ),
380 );
381 });
382
383 if let Ok(paths) = paths {
384 if !paths.is_object() {
385 errors.push(
386 ValidationError::new("'paths' must be an object".to_string())
387 .at_path("/paths".to_string())
388 .with_code("INVALID_PATHS_TYPE".to_string()),
389 );
390 } else if paths.as_object().map(|m| m.is_empty()) == Some(true) {
391 errors.push(
392 ValidationError::new(
393 "'paths' object cannot be empty. At least one endpoint is required."
394 .to_string(),
395 )
396 .at_path("/paths".to_string())
397 .with_code("EMPTY_PATHS".to_string())
398 .with_suggestion(
399 "Add at least one path definition, e.g., '/users': { 'get': { ... } }"
400 .to_string(),
401 ),
402 );
403 }
404 }
405 }
406}
407
408pub struct GraphQLValidator;
413
414impl GraphQLValidator {
415 pub fn validate(content: &str) -> ValidationResult {
421 let errors = Vec::new();
422 let mut warnings = Vec::new();
423
424 if content.trim().is_empty() {
426 return ValidationResult::failure(vec![ValidationError::new(
427 "GraphQL schema cannot be empty".to_string(),
428 )
429 .with_code("EMPTY_SCHEMA".to_string())]);
430 }
431
432 let content_trimmed = content.trim();
435
436 if !content_trimmed.contains("type") && !content_trimmed.contains("schema") {
438 warnings
439 .push("Schema doesn't appear to contain any GraphQL type definitions.".to_string());
440 }
441
442 Self::check_schema_completeness(content, &mut warnings);
444
445 if errors.is_empty() {
447 if warnings.is_empty() {
448 ValidationResult::success()
449 } else {
450 ValidationResult::success().with_warnings(warnings)
451 }
452 } else {
453 ValidationResult::failure(errors)
454 }
455 }
456
457 fn check_schema_completeness(content: &str, warnings: &mut Vec<String>) {
459 if !content.contains("type Query") && !content.contains("extend type Query") {
461 warnings.push(
462 "Schema does not define a Query type. GraphQL schemas typically need a Query type."
463 .to_string(),
464 );
465 }
466
467 if !content.contains(":") && !content.contains("{") {
469 warnings.push("Schema appears to be empty or incomplete.".to_string());
470 }
471 }
472}
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477
478 #[test]
479 fn test_detect_openapi_30_json() {
480 let content =
481 r#"{"openapi": "3.0.0", "info": {"title": "Test", "version": "1.0.0"}, "paths": {}}"#;
482 let format = SpecFormat::detect(content, None).unwrap();
483 assert_eq!(format, SpecFormat::OpenApi30);
484 }
485
486 #[test]
487 fn test_detect_openapi_31_yaml() {
488 let content = "openapi: 3.1.0\ninfo:\n title: Test\n version: 1.0.0\npaths: {}";
489 let format = SpecFormat::detect(content, None).unwrap();
490 assert_eq!(format, SpecFormat::OpenApi31);
491 }
492
493 #[test]
494 fn test_detect_swagger_20() {
495 let content =
496 r#"{"swagger": "2.0", "info": {"title": "Test", "version": "1.0.0"}, "paths": {}}"#;
497 let format = SpecFormat::detect(content, None).unwrap();
498 assert_eq!(format, SpecFormat::OpenApi20);
499 }
500
501 #[test]
502 fn test_detect_graphql_from_extension() {
503 let path = std::path::Path::new("schema.graphql");
504 let content = "type Query { users: [User] }";
505 let format = SpecFormat::detect(content, Some(path)).unwrap();
506 assert_eq!(format, SpecFormat::GraphQL);
507 }
508
509 #[test]
510 fn test_detect_graphql_from_content() {
511 let content = "type Query { users: [User!]! } type User { id: ID! name: String }";
512 let format = SpecFormat::detect(content, None).unwrap();
513 assert_eq!(format, SpecFormat::GraphQL);
514 }
515
516 #[test]
517 fn test_validate_openapi_30_valid() {
518 let spec = serde_json::json!({
519 "openapi": "3.0.0",
520 "info": {
521 "title": "Test API",
522 "version": "1.0.0"
523 },
524 "paths": {
525 "/users": {
526 "get": {
527 "responses": {
528 "200": {
529 "description": "Success"
530 }
531 }
532 }
533 }
534 }
535 });
536 let result = OpenApiValidator::validate(&spec, SpecFormat::OpenApi30);
537 assert!(result.is_valid);
538 assert!(!result.has_errors());
539 }
540
541 #[test]
542 fn test_validate_openapi_30_missing_info() {
543 let spec = serde_json::json!({
544 "openapi": "3.0.0",
545 "paths": {}
546 });
547 let result = OpenApiValidator::validate(&spec, SpecFormat::OpenApi30);
548 assert!(!result.is_valid);
549 assert!(result.has_errors());
550 assert!(result.errors.iter().any(|e| e.code.as_deref() == Some("MISSING_INFO")));
551 }
552
553 #[test]
554 fn test_validate_openapi_30_empty_paths() {
555 let spec = serde_json::json!({
556 "openapi": "3.0.0",
557 "info": {
558 "title": "Test",
559 "version": "1.0.0"
560 },
561 "paths": {}
562 });
563 let result = OpenApiValidator::validate(&spec, SpecFormat::OpenApi30);
564 assert!(!result.is_valid);
565 assert!(result.has_errors());
566 assert!(result.errors.iter().any(|e| e.code.as_deref() == Some("EMPTY_PATHS")));
567 }
568
569 #[test]
570 fn test_validate_swagger_20_valid() {
571 let spec = serde_json::json!({
572 "swagger": "2.0",
573 "info": {
574 "title": "Test API",
575 "version": "1.0.0"
576 },
577 "paths": {
578 "/users": {
579 "get": {
580 "responses": {
581 "200": {
582 "description": "Success"
583 }
584 }
585 }
586 }
587 }
588 });
589 let result = OpenApiValidator::validate(&spec, SpecFormat::OpenApi20);
590 assert!(result.is_valid);
591 }
592
593 #[test]
594 fn test_validate_graphql_valid() {
595 let schema = "type Query { users: [User!]! } type User { id: ID! name: String }";
596 let result = GraphQLValidator::validate(schema);
597 assert!(result.is_valid);
598 assert!(!result.has_errors());
599 }
600
601 #[test]
602 fn test_validate_graphql_invalid() {
603 let schema = "type Query { users: [User!]! }"; let result = GraphQLValidator::validate(schema);
605 assert!(!result.has_errors() || result.errors.len() > 0);
608 }
609
610 #[test]
611 fn test_validate_openapi_30_missing_title() {
612 let spec = serde_json::json!({
613 "openapi": "3.0.0",
614 "info": {
615 "version": "1.0.0"
616 },
617 "paths": {
618 "/users": {
619 "get": {
620 "responses": {
621 "200": {"description": "Success"}
622 }
623 }
624 }
625 }
626 });
627 let result = OpenApiValidator::validate(&spec, SpecFormat::OpenApi30);
628 assert!(!result.is_valid);
629 assert!(result.errors.iter().any(|e| e.code.as_deref() == Some("MISSING_TITLE")));
630 }
631
632 #[test]
633 fn test_validate_openapi_30_missing_version() {
634 let spec = serde_json::json!({
635 "openapi": "3.0.0",
636 "info": {
637 "title": "Test API"
638 },
639 "paths": {
640 "/users": {
641 "get": {
642 "responses": {
643 "200": {"description": "Success"}
644 }
645 }
646 }
647 }
648 });
649 let result = OpenApiValidator::validate(&spec, SpecFormat::OpenApi30);
650 assert!(!result.is_valid);
651 assert!(result.errors.iter().any(|e| e.code.as_deref() == Some("MISSING_VERSION")));
652 }
653
654 #[test]
655 fn test_validate_swagger_20_missing_paths() {
656 let spec = serde_json::json!({
657 "swagger": "2.0",
658 "info": {
659 "title": "Test API",
660 "version": "1.0.0"
661 }
662 });
663 let result = OpenApiValidator::validate(&spec, SpecFormat::OpenApi20);
664 assert!(!result.is_valid);
665 assert!(result.errors.iter().any(|e| e.code.as_deref() == Some("MISSING_PATHS")));
666 }
667
668 #[test]
669 fn test_validate_error_with_suggestion() {
670 let spec = serde_json::json!({
671 "openapi": "3.0.0",
672 "paths": {}
673 });
674 let result = OpenApiValidator::validate(&spec, SpecFormat::OpenApi30);
675 assert!(!result.is_valid);
676 let errors_with_suggestions: Vec<_> =
678 result.errors.iter().filter(|e| e.suggestion.is_some()).collect();
679 assert!(!errors_with_suggestions.is_empty());
680 }
681
682 #[test]
683 fn test_validate_graphql_empty() {
684 let result = GraphQLValidator::validate("");
685 assert!(!result.is_valid);
686 assert!(result.errors.iter().any(|e| e.code.as_deref() == Some("EMPTY_SCHEMA")));
687 }
688
689 #[test]
690 fn test_validate_graphql_with_warnings() {
691 let schema = "type User { id: ID! name: String }"; let result = GraphQLValidator::validate(schema);
693 assert!(result.is_valid || !result.errors.is_empty());
695 assert!(result.warnings.iter().any(|w| w.contains("Query")));
697 }
698
699 #[test]
700 fn test_spec_format_display_name() {
701 assert_eq!(SpecFormat::OpenApi20.display_name(), "OpenAPI 2.0 (Swagger)");
702 assert_eq!(SpecFormat::OpenApi30.display_name(), "OpenAPI 3.0.x");
703 assert_eq!(SpecFormat::OpenApi31.display_name(), "OpenAPI 3.1.x");
704 assert_eq!(SpecFormat::GraphQL.display_name(), "GraphQL Schema");
705 assert_eq!(SpecFormat::Protobuf.display_name(), "Protocol Buffers");
706 }
707
708 #[test]
709 fn test_validation_result_with_warnings() {
710 let result = ValidationResult::success().with_warning("Test warning".to_string());
711 assert!(result.is_valid);
712 assert_eq!(result.warnings.len(), 1);
713 assert_eq!(result.warnings[0], "Test warning");
714 }
715
716 #[test]
717 fn test_detect_yaml_with_whitespace() {
718 let content =
719 "\n\n openapi: 3.0.0\n info:\n title: Test\n version: 1.0.0\n paths: {}";
720 let format = SpecFormat::detect(content, None).unwrap();
721 assert_eq!(format, SpecFormat::OpenApi30);
722 }
723
724 #[test]
725 fn test_detect_yaml_with_comments() {
726 let content = "# This is a YAML comment\nopenapi: 3.0.0\ninfo:\n title: Test\n version: 1.0.0\npaths: {}";
727 let format = SpecFormat::detect(content, None).unwrap();
728 assert_eq!(format, SpecFormat::OpenApi30);
729 }
730
731 #[test]
732 fn test_detect_yaml_with_leading_whitespace() {
733 let content =
734 " openapi: 3.0.0\n info:\n title: Test\n version: 1.0.0\n paths: {}";
735 let format = SpecFormat::detect(content, None).unwrap();
736 assert_eq!(format, SpecFormat::OpenApi30);
737 }
738
739 #[test]
740 fn test_detect_swagger_yaml() {
741 let content = "swagger: \"2.0\"\ninfo:\n title: Test API\n version: 1.0.0\npaths:\n /test:\n get:\n responses:\n '200':\n description: OK";
742 let format = SpecFormat::detect(content, None).unwrap();
743 assert_eq!(format, SpecFormat::OpenApi20);
744 }
745
746 #[test]
747 fn test_validate_common_sections_shared_logic() {
748 let spec_20 = serde_json::json!({
750 "swagger": "2.0",
751 "info": {
752 "title": "Test",
753 "version": "1.0.0"
754 },
755 "paths": {
756 "/test": {
757 "get": {
758 "responses": {
759 "200": {"description": "OK"}
760 }
761 }
762 }
763 }
764 });
765
766 let spec_30 = serde_json::json!({
767 "openapi": "3.0.0",
768 "info": {
769 "title": "Test",
770 "version": "1.0.0"
771 },
772 "paths": {
773 "/test": {
774 "get": {
775 "responses": {
776 "200": {"description": "OK"}
777 }
778 }
779 }
780 }
781 });
782
783 let result_20 = OpenApiValidator::validate(&spec_20, SpecFormat::OpenApi20);
784 let result_30 = OpenApiValidator::validate(&spec_30, SpecFormat::OpenApi30);
785
786 assert!(result_20.is_valid);
787 assert!(result_30.is_valid);
788 }
789
790 #[test]
791 fn test_validate_version_field_extraction() {
792 let spec = serde_json::json!({
793 "info": {
794 "title": "Test",
795 "version": "1.0.0"
796 },
797 "paths": {}
798 });
799
800 let result = OpenApiValidator::validate(&spec, SpecFormat::OpenApi30);
802 assert!(!result.is_valid);
803 assert!(result.errors.iter().any(|e| e.code.as_deref() == Some("MISSING_OPENAPI_FIELD")));
804 }
805}