use crate::{Error, Result};
use serde_json::Value;
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SpecFormat {
OpenApi20,
OpenApi30,
OpenApi31,
GraphQL,
Protobuf,
}
impl SpecFormat {
pub fn detect(content: &str, file_path: Option<&Path>) -> Result<Self> {
if let Some(path) = file_path {
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
match ext.to_lowercase().as_str() {
"graphql" | "gql" => return Ok(Self::GraphQL),
"proto" => return Ok(Self::Protobuf),
_ => {}
}
}
}
let is_likely_json = |s: &str| {
let trimmed = s.trim();
trimmed.starts_with('{') || trimmed.starts_with('[')
};
let is_likely_yaml = |s: &str| {
let trimmed = s.trim();
!is_likely_json(s)
&& (trimmed.contains(":\n")
|| trimmed.contains(": ")
|| trimmed.starts_with('#')
|| trimmed.contains('\n'))
};
if is_likely_json(content) {
if let Ok(json) = serde_json::from_str::<Value>(content) {
if json.get("swagger").is_some() {
if let Some(swagger_version) = json.get("swagger").and_then(|v| v.as_str()) {
if swagger_version.starts_with("2.") {
return Ok(Self::OpenApi20);
}
}
}
if json.get("openapi").is_some() {
if let Some(openapi_version) = json.get("openapi").and_then(|v| v.as_str()) {
if openapi_version.starts_with("3.0") {
return Ok(Self::OpenApi30);
} else if openapi_version.starts_with("3.1") {
return Ok(Self::OpenApi31);
}
}
}
}
}
if is_likely_yaml(content) || !is_likely_json(content) {
if let Ok(yaml) = serde_yaml::from_str::<Value>(content) {
if yaml.get("swagger").is_some() {
return Ok(Self::OpenApi20);
}
if yaml.get("openapi").is_some() {
if let Some(openapi_version) = yaml.get("openapi").and_then(|v| v.as_str()) {
if openapi_version.starts_with("3.0") {
return Ok(Self::OpenApi30);
} else if openapi_version.starts_with("3.1") {
return Ok(Self::OpenApi31);
}
}
}
}
}
let content_lower = content.trim().to_lowercase();
if content_lower.contains("type ")
&& (content_lower.contains("query") || content_lower.contains("mutation"))
{
return Ok(Self::GraphQL);
}
Err(Error::validation(
"Could not detect specification format. \
Expected OpenAPI (2.0/3.x), GraphQL schema, or protobuf definition."
.to_string(),
))
}
pub fn display_name(&self) -> &'static str {
match self {
Self::OpenApi20 => "OpenAPI 2.0 (Swagger)",
Self::OpenApi30 => "OpenAPI 3.0.x",
Self::OpenApi31 => "OpenAPI 3.1.x",
Self::GraphQL => "GraphQL Schema",
Self::Protobuf => "Protocol Buffers",
}
}
}
#[derive(Debug, Clone)]
pub struct ValidationResult {
pub is_valid: bool,
pub errors: Vec<ValidationError>,
pub warnings: Vec<String>,
}
impl ValidationResult {
pub fn success() -> Self {
Self {
is_valid: true,
errors: vec![],
warnings: vec![],
}
}
pub fn failure(errors: Vec<ValidationError>) -> Self {
Self {
is_valid: false,
errors,
warnings: vec![],
}
}
pub fn with_warning(mut self, warning: String) -> Self {
self.warnings.push(warning);
self
}
pub fn with_warnings(mut self, warnings: Vec<String>) -> Self {
self.warnings.extend(warnings);
self
}
pub fn has_errors(&self) -> bool {
!self.errors.is_empty()
}
}
#[derive(Debug, Clone)]
pub struct ValidationError {
pub message: String,
pub path: Option<String>,
pub code: Option<String>,
pub suggestion: Option<String>,
}
impl ValidationError {
pub fn new(message: String) -> Self {
Self {
message,
path: None,
code: None,
suggestion: None,
}
}
pub fn at_path(mut self, path: String) -> Self {
self.path = Some(path);
self
}
pub fn with_code(mut self, code: String) -> Self {
self.code = Some(code);
self
}
pub fn with_suggestion(mut self, suggestion: String) -> Self {
self.suggestion = Some(suggestion);
self
}
}
impl std::fmt::Display for ValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.message)?;
if let Some(path) = &self.path {
write!(f, " (at {})", path)?;
}
if let Some(suggestion) = &self.suggestion {
write!(f, ". Suggestion: {}", suggestion)?;
}
Ok(())
}
}
pub struct OpenApiValidator;
impl OpenApiValidator {
pub fn validate(spec: &Value, format: SpecFormat) -> ValidationResult {
let mut errors = Vec::new();
if !spec.is_object() {
return ValidationResult::failure(vec![ValidationError::new(
"OpenAPI specification must be a JSON object".to_string(),
)
.with_code("INVALID_ROOT".to_string())]);
}
match format {
SpecFormat::OpenApi20 => {
Self::validate_version_field(
spec,
"swagger",
&mut errors,
"/swagger",
"OpenAPI 2.0",
);
Self::validate_common_sections(spec, &mut errors, "OpenAPI 2.0");
}
SpecFormat::OpenApi30 | SpecFormat::OpenApi31 => {
Self::validate_version_field(
spec,
"openapi",
&mut errors,
"/openapi",
"OpenAPI 3.x",
);
if let Some(version) = spec.get("openapi").and_then(|v| v.as_str()) {
if !version.starts_with("3.") {
errors.push(
ValidationError::new(format!(
"Invalid OpenAPI version '{}'. Expected 3.0.x or 3.1.x",
version
))
.at_path("/openapi".to_string())
.with_code("INVALID_VERSION".to_string())
.with_suggestion(
"Use 'openapi': '3.0.0' or 'openapi': '3.1.0'".to_string(),
),
);
}
}
Self::validate_common_sections(spec, &mut errors, "OpenAPI 3.x");
}
_ => {
errors.push(ValidationError::new(
"Invalid format for OpenAPI validation".to_string(),
));
}
}
if errors.is_empty() {
ValidationResult::success()
} else {
ValidationResult::failure(errors)
}
}
fn validate_version_field(
spec: &Value,
field_name: &str,
errors: &mut Vec<ValidationError>,
path: &str,
spec_type: &str,
) {
let _version = spec.get(field_name).and_then(|v| v.as_str()).ok_or_else(|| {
errors.push(
ValidationError::new(format!(
"Missing '{}' field in {} spec",
field_name, spec_type
))
.at_path(path.to_string())
.with_code(format!("MISSING_{}_FIELD", field_name.to_uppercase()))
.with_suggestion(format!(
"Add '{}': '{}' to the root of the specification",
field_name,
if field_name == "swagger" {
"2.0"
} else {
"3.0.0 or 3.1.0"
}
)),
);
});
}
fn validate_common_sections(spec: &Value, errors: &mut Vec<ValidationError>, spec_type: &str) {
let info = spec.get("info").ok_or_else(|| {
errors.push(
ValidationError::new(format!("Missing 'info' section in {} spec", spec_type))
.at_path("/info".to_string())
.with_code("MISSING_INFO".to_string())
.with_suggestion(
"Add an 'info' section with 'title' and 'version' fields".to_string(),
),
);
});
if let Ok(info) = info {
if info.get("title").is_none()
|| info.get("title").and_then(|t| t.as_str()).map(|s| s.is_empty()) == Some(true)
{
errors.push(
ValidationError::new("Missing or empty 'info.title' field".to_string())
.at_path("/info/title".to_string())
.with_code("MISSING_TITLE".to_string())
.with_suggestion("Add 'title' field to the 'info' section".to_string()),
);
}
if info.get("version").is_none()
|| info.get("version").and_then(|v| v.as_str()).map(|s| s.is_empty()) == Some(true)
{
errors.push(
ValidationError::new("Missing or empty 'info.version' field".to_string())
.at_path("/info/version".to_string())
.with_code("MISSING_VERSION".to_string())
.with_suggestion("Add 'version' field to the 'info' section".to_string()),
);
}
}
let paths = spec.get("paths").ok_or_else(|| {
errors.push(
ValidationError::new(format!(
"Missing 'paths' section in {} spec. At least one endpoint is required.",
spec_type
))
.at_path("/paths".to_string())
.with_code("MISSING_PATHS".to_string())
.with_suggestion(
"Add a 'paths' section with at least one endpoint definition".to_string(),
),
);
});
if let Ok(paths) = paths {
if !paths.is_object() {
errors.push(
ValidationError::new("'paths' must be an object".to_string())
.at_path("/paths".to_string())
.with_code("INVALID_PATHS_TYPE".to_string()),
);
} else if paths.as_object().map(|m| m.is_empty()) == Some(true) {
errors.push(
ValidationError::new(
"'paths' object cannot be empty. At least one endpoint is required."
.to_string(),
)
.at_path("/paths".to_string())
.with_code("EMPTY_PATHS".to_string())
.with_suggestion(
"Add at least one path definition, e.g., '/users': { 'get': { ... } }"
.to_string(),
),
);
}
}
}
}
pub struct GraphQLValidator;
impl GraphQLValidator {
pub fn validate(content: &str) -> ValidationResult {
let errors = Vec::new();
let mut warnings = Vec::new();
if content.trim().is_empty() {
return ValidationResult::failure(vec![ValidationError::new(
"GraphQL schema cannot be empty".to_string(),
)
.with_code("EMPTY_SCHEMA".to_string())]);
}
let content_trimmed = content.trim();
if !content_trimmed.contains("type") && !content_trimmed.contains("schema") {
warnings
.push("Schema doesn't appear to contain any GraphQL type definitions.".to_string());
}
Self::check_schema_completeness(content, &mut warnings);
if errors.is_empty() {
if warnings.is_empty() {
ValidationResult::success()
} else {
ValidationResult::success().with_warnings(warnings)
}
} else {
ValidationResult::failure(errors)
}
}
fn check_schema_completeness(content: &str, warnings: &mut Vec<String>) {
if !content.contains("type Query") && !content.contains("extend type Query") {
warnings.push(
"Schema does not define a Query type. GraphQL schemas typically need a Query type."
.to_string(),
);
}
if !content.contains(":") && !content.contains("{") {
warnings.push("Schema appears to be empty or incomplete.".to_string());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_detect_openapi_30_json() {
let content =
r#"{"openapi": "3.0.0", "info": {"title": "Test", "version": "1.0.0"}, "paths": {}}"#;
let format = SpecFormat::detect(content, None).unwrap();
assert_eq!(format, SpecFormat::OpenApi30);
}
#[test]
fn test_detect_openapi_31_yaml() {
let content = "openapi: 3.1.0\ninfo:\n title: Test\n version: 1.0.0\npaths: {}";
let format = SpecFormat::detect(content, None).unwrap();
assert_eq!(format, SpecFormat::OpenApi31);
}
#[test]
fn test_detect_swagger_20() {
let content =
r#"{"swagger": "2.0", "info": {"title": "Test", "version": "1.0.0"}, "paths": {}}"#;
let format = SpecFormat::detect(content, None).unwrap();
assert_eq!(format, SpecFormat::OpenApi20);
}
#[test]
fn test_detect_graphql_from_extension() {
let path = Path::new("schema.graphql");
let content = "type Query { users: [User] }";
let format = SpecFormat::detect(content, Some(path)).unwrap();
assert_eq!(format, SpecFormat::GraphQL);
}
#[test]
fn test_detect_graphql_from_content() {
let content = "type Query { users: [User!]! } type User { id: ID! name: String }";
let format = SpecFormat::detect(content, None).unwrap();
assert_eq!(format, SpecFormat::GraphQL);
}
#[test]
fn test_validate_openapi_30_valid() {
let spec = serde_json::json!({
"openapi": "3.0.0",
"info": {
"title": "Test API",
"version": "1.0.0"
},
"paths": {
"/users": {
"get": {
"responses": {
"200": {
"description": "Success"
}
}
}
}
}
});
let result = OpenApiValidator::validate(&spec, SpecFormat::OpenApi30);
assert!(result.is_valid);
assert!(!result.has_errors());
}
#[test]
fn test_validate_openapi_30_missing_info() {
let spec = serde_json::json!({
"openapi": "3.0.0",
"paths": {}
});
let result = OpenApiValidator::validate(&spec, SpecFormat::OpenApi30);
assert!(!result.is_valid);
assert!(result.has_errors());
assert!(result.errors.iter().any(|e| e.code.as_deref() == Some("MISSING_INFO")));
}
#[test]
fn test_validate_openapi_30_empty_paths() {
let spec = serde_json::json!({
"openapi": "3.0.0",
"info": {
"title": "Test",
"version": "1.0.0"
},
"paths": {}
});
let result = OpenApiValidator::validate(&spec, SpecFormat::OpenApi30);
assert!(!result.is_valid);
assert!(result.has_errors());
assert!(result.errors.iter().any(|e| e.code.as_deref() == Some("EMPTY_PATHS")));
}
#[test]
fn test_validate_swagger_20_valid() {
let spec = serde_json::json!({
"swagger": "2.0",
"info": {
"title": "Test API",
"version": "1.0.0"
},
"paths": {
"/users": {
"get": {
"responses": {
"200": {
"description": "Success"
}
}
}
}
}
});
let result = OpenApiValidator::validate(&spec, SpecFormat::OpenApi20);
assert!(result.is_valid);
}
#[test]
fn test_validate_graphql_valid() {
let schema = "type Query { users: [User!]! } type User { id: ID! name: String }";
let result = GraphQLValidator::validate(schema);
assert!(result.is_valid);
assert!(!result.has_errors());
}
#[test]
fn test_validate_graphql_invalid() {
let schema = "type Query { users: [User!]! }"; let result = GraphQLValidator::validate(schema);
assert!(!result.has_errors() || !result.errors.is_empty());
}
#[test]
fn test_validate_openapi_30_missing_title() {
let spec = serde_json::json!({
"openapi": "3.0.0",
"info": {
"version": "1.0.0"
},
"paths": {
"/users": {
"get": {
"responses": {
"200": {"description": "Success"}
}
}
}
}
});
let result = OpenApiValidator::validate(&spec, SpecFormat::OpenApi30);
assert!(!result.is_valid);
assert!(result.errors.iter().any(|e| e.code.as_deref() == Some("MISSING_TITLE")));
}
#[test]
fn test_validate_openapi_30_missing_version() {
let spec = serde_json::json!({
"openapi": "3.0.0",
"info": {
"title": "Test API"
},
"paths": {
"/users": {
"get": {
"responses": {
"200": {"description": "Success"}
}
}
}
}
});
let result = OpenApiValidator::validate(&spec, SpecFormat::OpenApi30);
assert!(!result.is_valid);
assert!(result.errors.iter().any(|e| e.code.as_deref() == Some("MISSING_VERSION")));
}
#[test]
fn test_validate_swagger_20_missing_paths() {
let spec = serde_json::json!({
"swagger": "2.0",
"info": {
"title": "Test API",
"version": "1.0.0"
}
});
let result = OpenApiValidator::validate(&spec, SpecFormat::OpenApi20);
assert!(!result.is_valid);
assert!(result.errors.iter().any(|e| e.code.as_deref() == Some("MISSING_PATHS")));
}
#[test]
fn test_validate_error_with_suggestion() {
let spec = serde_json::json!({
"openapi": "3.0.0",
"paths": {}
});
let result = OpenApiValidator::validate(&spec, SpecFormat::OpenApi30);
assert!(!result.is_valid);
let errors_with_suggestions: Vec<_> =
result.errors.iter().filter(|e| e.suggestion.is_some()).collect();
assert!(!errors_with_suggestions.is_empty());
}
#[test]
fn test_validate_graphql_empty() {
let result = GraphQLValidator::validate("");
assert!(!result.is_valid);
assert!(result.errors.iter().any(|e| e.code.as_deref() == Some("EMPTY_SCHEMA")));
}
#[test]
fn test_validate_graphql_with_warnings() {
let schema = "type User { id: ID! name: String }"; let result = GraphQLValidator::validate(schema);
assert!(result.is_valid || !result.errors.is_empty());
assert!(result.warnings.iter().any(|w| w.contains("Query")));
}
#[test]
fn test_spec_format_display_name() {
assert_eq!(SpecFormat::OpenApi20.display_name(), "OpenAPI 2.0 (Swagger)");
assert_eq!(SpecFormat::OpenApi30.display_name(), "OpenAPI 3.0.x");
assert_eq!(SpecFormat::OpenApi31.display_name(), "OpenAPI 3.1.x");
assert_eq!(SpecFormat::GraphQL.display_name(), "GraphQL Schema");
assert_eq!(SpecFormat::Protobuf.display_name(), "Protocol Buffers");
}
#[test]
fn test_validation_result_with_warnings() {
let result = ValidationResult::success().with_warning("Test warning".to_string());
assert!(result.is_valid);
assert_eq!(result.warnings.len(), 1);
assert_eq!(result.warnings[0], "Test warning");
}
#[test]
fn test_detect_yaml_with_whitespace() {
let content =
"\n\n openapi: 3.0.0\n info:\n title: Test\n version: 1.0.0\n paths: {}";
let format = SpecFormat::detect(content, None).unwrap();
assert_eq!(format, SpecFormat::OpenApi30);
}
#[test]
fn test_detect_yaml_with_comments() {
let content = "# This is a YAML comment\nopenapi: 3.0.0\ninfo:\n title: Test\n version: 1.0.0\npaths: {}";
let format = SpecFormat::detect(content, None).unwrap();
assert_eq!(format, SpecFormat::OpenApi30);
}
#[test]
fn test_detect_yaml_with_leading_whitespace() {
let content =
" openapi: 3.0.0\n info:\n title: Test\n version: 1.0.0\n paths: {}";
let format = SpecFormat::detect(content, None).unwrap();
assert_eq!(format, SpecFormat::OpenApi30);
}
#[test]
fn test_detect_swagger_yaml() {
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";
let format = SpecFormat::detect(content, None).unwrap();
assert_eq!(format, SpecFormat::OpenApi20);
}
#[test]
fn test_validate_common_sections_shared_logic() {
let spec_20 = serde_json::json!({
"swagger": "2.0",
"info": {
"title": "Test",
"version": "1.0.0"
},
"paths": {
"/test": {
"get": {
"responses": {
"200": {"description": "OK"}
}
}
}
}
});
let spec_30 = serde_json::json!({
"openapi": "3.0.0",
"info": {
"title": "Test",
"version": "1.0.0"
},
"paths": {
"/test": {
"get": {
"responses": {
"200": {"description": "OK"}
}
}
}
}
});
let result_20 = OpenApiValidator::validate(&spec_20, SpecFormat::OpenApi20);
let result_30 = OpenApiValidator::validate(&spec_30, SpecFormat::OpenApi30);
assert!(result_20.is_valid);
assert!(result_30.is_valid);
}
#[test]
fn test_validate_version_field_extraction() {
let spec = serde_json::json!({
"info": {
"title": "Test",
"version": "1.0.0"
},
"paths": {}
});
let result = OpenApiValidator::validate(&spec, SpecFormat::OpenApi30);
assert!(!result.is_valid);
assert!(result.errors.iter().any(|e| e.code.as_deref() == Some("MISSING_OPENAPI_FIELD")));
}
}