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