use crate::error::BuildError;
use crate::presets::{DdexVersion, MessageProfile, PartnerPreset};
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value as JsonValue};
mod generators;
mod types;
mod validation;
pub use validation::{
SchemaValidator, ValidationConfig as SchemaValidationConfig,
ValidationResult as SchemaValidationResult,
};
#[derive(Debug, Clone)]
pub struct SchemaGenerator {
version: DdexVersion,
profile: MessageProfile,
#[allow(dead_code)]
preset: Option<PartnerPreset>,
config: SchemaConfig,
}
#[derive(Debug, Clone)]
pub struct SchemaConfig {
pub draft_version: SchemaDraft,
pub include_examples: bool,
pub include_descriptions: bool,
pub strict_validation: bool,
pub include_deprecated: bool,
pub version_conditionals: bool,
}
#[derive(Debug, Clone, Copy)]
pub enum SchemaDraft {
Draft202012,
Draft07,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonSchema {
#[serde(rename = "$schema")]
pub schema: String,
#[serde(rename = "$id", skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub schema_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub properties: Option<IndexMap<String, JsonSchema>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub required: Option<Vec<String>>,
#[serde(
rename = "additionalProperties",
skip_serializing_if = "Option::is_none"
)]
pub additional_properties: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub items: Option<Box<JsonSchema>>,
#[serde(rename = "enum", skip_serializing_if = "Option::is_none")]
pub enum_values: Option<Vec<JsonValue>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pattern: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub format: Option<String>,
#[serde(rename = "minLength", skip_serializing_if = "Option::is_none")]
pub min_length: Option<usize>,
#[serde(rename = "maxLength", skip_serializing_if = "Option::is_none")]
pub max_length: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub examples: Option<Vec<JsonValue>>,
#[serde(rename = "$defs", skip_serializing_if = "Option::is_none")]
pub definitions: Option<IndexMap<String, JsonSchema>>,
#[serde(rename = "$ref", skip_serializing_if = "Option::is_none")]
pub reference: Option<String>,
#[serde(rename = "allOf", skip_serializing_if = "Option::is_none")]
pub all_of: Option<Vec<JsonSchema>>,
#[serde(rename = "anyOf", skip_serializing_if = "Option::is_none")]
pub any_of: Option<Vec<JsonSchema>>,
#[serde(rename = "oneOf", skip_serializing_if = "Option::is_none")]
pub one_of: Option<Vec<JsonSchema>>,
#[serde(rename = "if", skip_serializing_if = "Option::is_none")]
pub if_schema: Option<Box<JsonSchema>>,
#[serde(rename = "then", skip_serializing_if = "Option::is_none")]
pub then_schema: Option<Box<JsonSchema>>,
#[serde(rename = "else", skip_serializing_if = "Option::is_none")]
pub else_schema: Option<Box<JsonSchema>>,
#[serde(flatten)]
pub annotations: IndexMap<String, JsonValue>,
}
#[derive(Debug, Clone)]
pub struct SchemaGenerationResult {
pub schema: JsonSchema,
pub metadata: SchemaMetadata,
pub warnings: Vec<SchemaWarning>,
}
#[derive(Debug, Clone)]
pub struct SchemaMetadata {
pub ddex_version: DdexVersion,
pub profile: MessageProfile,
pub draft_version: SchemaDraft,
pub generated_at: chrono::DateTime<chrono::Utc>,
pub property_count: usize,
pub required_count: usize,
pub complexity_score: f64,
}
#[derive(Debug, Clone)]
pub struct SchemaWarning {
pub code: String,
pub message: String,
pub field_path: Option<String>,
pub suggestion: Option<String>,
}
impl Default for SchemaConfig {
fn default() -> Self {
Self {
draft_version: SchemaDraft::Draft202012,
include_examples: true,
include_descriptions: true,
strict_validation: true,
include_deprecated: false,
version_conditionals: true,
}
}
}
impl SchemaGenerator {
pub fn new(version: DdexVersion, profile: MessageProfile) -> Self {
Self {
version,
profile,
preset: None,
config: SchemaConfig::default(),
}
}
pub fn with_preset(
version: DdexVersion,
profile: MessageProfile,
preset: PartnerPreset,
) -> Self {
Self {
version,
profile,
preset: Some(preset),
config: SchemaConfig::default(),
}
}
pub fn with_config(mut self, config: SchemaConfig) -> Self {
self.config = config;
self
}
pub fn generate_build_request_schema(&self) -> Result<SchemaGenerationResult, BuildError> {
let mut warnings = Vec::new();
let schema = self.build_request_schema(&mut warnings)?;
let metadata = SchemaMetadata {
ddex_version: self.version,
profile: self.profile,
draft_version: self.config.draft_version,
generated_at: chrono::Utc::now(),
property_count: self.count_properties(&schema),
required_count: schema.required.as_ref().map(|r| r.len()).unwrap_or(0),
complexity_score: self.calculate_complexity(&schema),
};
Ok(SchemaGenerationResult {
schema,
metadata,
warnings,
})
}
pub fn generate_flat_release_schema(&self) -> Result<SchemaGenerationResult, BuildError> {
let mut warnings = Vec::new();
let schema = self.flat_release_schema(&mut warnings)?;
let metadata = SchemaMetadata {
ddex_version: self.version,
profile: self.profile,
draft_version: self.config.draft_version,
generated_at: chrono::Utc::now(),
property_count: self.count_properties(&schema),
required_count: schema.required.as_ref().map(|r| r.len()).unwrap_or(0),
complexity_score: self.calculate_complexity(&schema),
};
Ok(SchemaGenerationResult {
schema,
metadata,
warnings,
})
}
pub fn generate_complete_schema(&self) -> Result<SchemaGenerationResult, BuildError> {
let mut warnings = Vec::new();
let mut definitions = IndexMap::new();
definitions.insert(
"BuildRequest".to_string(),
self.build_request_schema(&mut warnings)?,
);
definitions.insert(
"ReleaseRequest".to_string(),
self.release_request_schema(&mut warnings)?,
);
definitions.insert(
"TrackRequest".to_string(),
self.track_request_schema(&mut warnings)?,
);
definitions.insert(
"DealRequest".to_string(),
self.deal_request_schema(&mut warnings)?,
);
definitions.insert(
"MessageHeader".to_string(),
self.message_header_schema(&mut warnings)?,
);
definitions.extend(self.common_type_definitions(&mut warnings)?);
let schema = JsonSchema {
schema: self.schema_draft_url(),
id: Some("https://ddex.net/schema/ern/builder".to_string()),
title: Some(format!(
"DDEX Builder Schema - ERN {} {}",
self.version_string(),
self.profile_string()
)),
description: Some(format!(
"Complete JSON Schema for DDEX Builder structures targeting ERN {} with {} profile",
self.version_string(),
self.profile_string()
)),
schema_type: Some("object".to_string()),
definitions: Some(definitions),
additional_properties: Some(false),
..Default::default()
};
let metadata = SchemaMetadata {
ddex_version: self.version,
profile: self.profile,
draft_version: self.config.draft_version,
generated_at: chrono::Utc::now(),
property_count: self.count_properties(&schema),
required_count: 0, complexity_score: self.calculate_complexity(&schema),
};
Ok(SchemaGenerationResult {
schema,
metadata,
warnings,
})
}
pub fn generate_typescript_types(&self, schema: &JsonSchema) -> Result<String, BuildError> {
let mut typescript = String::new();
typescript.push_str(&format!(
"// Generated TypeScript types for DDEX Builder - ERN {} {}\n",
self.version_string(),
self.profile_string()
));
typescript.push_str("// Generated at: ");
typescript.push_str(&chrono::Utc::now().to_rfc3339());
typescript.push_str("\n\n");
if let Some(ref definitions) = schema.definitions {
for (name, def_schema) in definitions {
typescript.push_str(&self.schema_to_typescript(name, def_schema)?);
typescript.push_str("\n\n");
}
}
Ok(typescript)
}
pub fn generate_python_types(&self, schema: &JsonSchema) -> Result<String, BuildError> {
let mut python = String::new();
python.push_str(&format!(
"# Generated Python TypedDict types for DDEX Builder - ERN {} {}\n",
self.version_string(),
self.profile_string()
));
python.push_str("# Generated at: ");
python.push_str(&chrono::Utc::now().to_rfc3339());
python.push_str("\n\n");
python.push_str("from typing import TypedDict, Optional, List, Union, Literal\nfrom datetime import datetime\n\n");
if let Some(ref definitions) = schema.definitions {
for (name, def_schema) in definitions {
python.push_str(&self.schema_to_python(name, def_schema)?);
python.push_str("\n\n");
}
}
Ok(python)
}
fn schema_draft_url(&self) -> String {
match self.config.draft_version {
SchemaDraft::Draft202012 => "https://json-schema.org/draft/2020-12/schema".to_string(),
SchemaDraft::Draft07 => "https://json-schema.org/draft-07/schema".to_string(),
}
}
fn version_string(&self) -> &str {
match self.version {
DdexVersion::Ern43 => "4.3",
DdexVersion::Ern42 => "4.2",
DdexVersion::Ern41 => "4.1",
DdexVersion::Ern382 => "3.8.2",
}
}
fn profile_string(&self) -> &str {
match self.profile {
MessageProfile::AudioAlbum => "AudioAlbum",
MessageProfile::AudioSingle => "AudioSingle",
MessageProfile::VideoAlbum => "VideoAlbum",
MessageProfile::VideoSingle => "VideoSingle",
MessageProfile::Mixed => "Mixed",
}
}
fn count_properties(&self, schema: &JsonSchema) -> usize {
let mut count = 0;
if let Some(ref properties) = schema.properties {
count += properties.len();
for (_, prop_schema) in properties {
count += self.count_properties(prop_schema);
}
}
if let Some(ref definitions) = schema.definitions {
for (_, def_schema) in definitions {
count += self.count_properties(def_schema);
}
}
count
}
fn calculate_complexity(&self, schema: &JsonSchema) -> f64 {
let mut complexity = 0.0;
if let Some(ref properties) = schema.properties {
complexity += properties.len() as f64;
for (_, prop_schema) in properties {
complexity += self.calculate_complexity(prop_schema) * 0.5;
}
}
if schema.all_of.is_some() {
complexity += 2.0;
}
if schema.any_of.is_some() {
complexity += 3.0;
}
if schema.one_of.is_some() {
complexity += 4.0;
}
if schema.if_schema.is_some() {
complexity += 5.0;
}
if schema.pattern.is_some() {
complexity += 1.0;
}
if schema.enum_values.is_some() {
complexity += 0.5;
}
complexity
}
}
impl Default for JsonSchema {
fn default() -> Self {
Self {
schema: String::new(),
id: None,
title: None,
description: None,
schema_type: None,
properties: None,
required: None,
additional_properties: None,
items: None,
enum_values: None,
pattern: None,
format: None,
min_length: None,
max_length: None,
examples: None,
definitions: None,
reference: None,
all_of: None,
any_of: None,
one_of: None,
if_schema: None,
then_schema: None,
else_schema: None,
annotations: IndexMap::new(),
}
}
}
impl std::fmt::Display for SchemaDraft {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SchemaDraft::Draft202012 => write!(f, "2020-12"),
SchemaDraft::Draft07 => write!(f, "draft-07"),
}
}
}
#[derive(Debug, Clone)]
pub struct SchemaCommand {
pub version: String,
pub profile: String,
pub output: Option<String>,
pub typescript: bool,
pub python: bool,
pub examples: bool,
pub strict: bool,
}
impl SchemaCommand {
pub fn execute(&self) -> Result<(), BuildError> {
let version = self.parse_version()?;
let profile = self.parse_profile()?;
let config = SchemaConfig {
include_examples: self.examples,
strict_validation: self.strict,
..Default::default()
};
let generator = SchemaGenerator::new(version, profile).with_config(config);
let result = generator.generate_complete_schema()?;
let schema_json = serde_json::to_string_pretty(&result.schema).map_err(|e| {
BuildError::InvalidFormat {
field: "schema".to_string(),
message: format!("Failed to serialize schema: {}", e),
}
})?;
if let Some(ref output_path) = self.output {
std::fs::write(output_path, &schema_json).map_err(|e| BuildError::InvalidFormat {
field: "output".to_string(),
message: format!("Failed to write schema: {}", e),
})?;
} else {
println!("{}", schema_json);
}
if self.typescript {
let ts_types = generator.generate_typescript_types(&result.schema)?;
let ts_path = self
.output
.as_ref()
.map(|p| p.replace(".json", ".d.ts"))
.unwrap_or_else(|| "ddex-types.d.ts".to_string());
std::fs::write(&ts_path, ts_types).map_err(|e| BuildError::InvalidFormat {
field: "typescript".to_string(),
message: format!("Failed to write TypeScript types: {}", e),
})?;
}
if self.python {
let py_types = generator.generate_python_types(&result.schema)?;
let py_path = self
.output
.as_ref()
.map(|p| p.replace(".json", ".py"))
.unwrap_or_else(|| "ddex_types.py".to_string());
std::fs::write(&py_path, py_types).map_err(|e| BuildError::InvalidFormat {
field: "python".to_string(),
message: format!("Failed to write Python types: {}", e),
})?;
}
Ok(())
}
fn parse_version(&self) -> Result<DdexVersion, BuildError> {
match self.version.as_str() {
"4.3" | "43" => Ok(DdexVersion::Ern43),
"4.2" | "42" => Ok(DdexVersion::Ern42),
"4.1" | "41" => Ok(DdexVersion::Ern41),
"3.8.2" | "382" => Ok(DdexVersion::Ern382),
_ => Err(BuildError::InvalidFormat {
field: "version".to_string(),
message: format!("Unsupported DDEX version: {}", self.version),
}),
}
}
fn parse_profile(&self) -> Result<MessageProfile, BuildError> {
match self.profile.to_lowercase().as_str() {
"audioalbum" | "audio-album" => Ok(MessageProfile::AudioAlbum),
"audiosingle" | "audio-single" => Ok(MessageProfile::AudioSingle),
"videoalbum" | "video-album" => Ok(MessageProfile::VideoAlbum),
"videosingle" | "video-single" => Ok(MessageProfile::VideoSingle),
"mixed" => Ok(MessageProfile::Mixed),
_ => Err(BuildError::InvalidFormat {
field: "profile".to_string(),
message: format!("Unsupported message profile: {}", self.profile),
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_schema_generator_creation() {
let generator = SchemaGenerator::new(DdexVersion::Ern43, MessageProfile::AudioAlbum);
assert_eq!(generator.version, DdexVersion::Ern43);
assert_eq!(generator.profile, MessageProfile::AudioAlbum);
assert!(generator.preset.is_none());
}
#[test]
fn test_schema_config_defaults() {
let config = SchemaConfig::default();
assert!(matches!(config.draft_version, SchemaDraft::Draft202012));
assert!(config.include_examples);
assert!(config.include_descriptions);
assert!(config.strict_validation);
assert!(!config.include_deprecated);
assert!(config.version_conditionals);
}
#[test]
fn test_build_request_schema_generation() {
let generator = SchemaGenerator::new(DdexVersion::Ern43, MessageProfile::AudioAlbum);
let result = generator.generate_build_request_schema().unwrap();
assert!(result.schema.title.is_some());
assert!(result.schema.schema_type == Some("object".to_string()));
assert!(result.schema.properties.is_some());
assert!(result.schema.required.is_some());
let properties = result.schema.properties.unwrap();
assert!(properties.contains_key("header"));
assert!(properties.contains_key("releases"));
let required = result.schema.required.unwrap();
assert!(required.contains(&"header".to_string()));
assert!(required.contains(&"releases".to_string()));
assert!(result.metadata.property_count > 0);
assert!(result.metadata.complexity_score > 0.0);
}
#[test]
fn test_complete_schema_generation() {
let generator = SchemaGenerator::new(DdexVersion::Ern43, MessageProfile::AudioAlbum);
let result = generator.generate_complete_schema().unwrap();
assert!(result.schema.definitions.is_some());
let definitions = result.schema.definitions.unwrap();
assert!(definitions.contains_key("BuildRequest"));
assert!(definitions.contains_key("ReleaseRequest"));
assert!(definitions.contains_key("TrackRequest"));
assert!(definitions.contains_key("DealRequest"));
assert!(definitions.contains_key("MessageHeader"));
assert!(definitions.contains_key("LocalizedString"));
assert!(definitions.contains_key("Party"));
assert!(definitions.contains_key("DealTerms"));
assert_eq!(
result.schema.schema,
"https://json-schema.org/draft/2020-12/schema"
);
assert!(result.schema.id.is_some());
assert!(result.schema.title.is_some());
assert!(result.schema.description.is_some());
}
#[test]
fn test_version_strings() {
let generator_43 = SchemaGenerator::new(DdexVersion::Ern43, MessageProfile::AudioAlbum);
let generator_42 = SchemaGenerator::new(DdexVersion::Ern42, MessageProfile::AudioAlbum);
let generator_41 = SchemaGenerator::new(DdexVersion::Ern41, MessageProfile::AudioAlbum);
assert_eq!(generator_43.version_string(), "4.3");
assert_eq!(generator_42.version_string(), "4.2");
assert_eq!(generator_41.version_string(), "4.1");
}
#[test]
fn test_profile_strings() {
let audio_album = SchemaGenerator::new(DdexVersion::Ern43, MessageProfile::AudioAlbum);
let audio_single = SchemaGenerator::new(DdexVersion::Ern43, MessageProfile::AudioSingle);
let video_album = SchemaGenerator::new(DdexVersion::Ern43, MessageProfile::VideoAlbum);
let video_single = SchemaGenerator::new(DdexVersion::Ern43, MessageProfile::VideoSingle);
let mixed = SchemaGenerator::new(DdexVersion::Ern43, MessageProfile::Mixed);
assert_eq!(audio_album.profile_string(), "AudioAlbum");
assert_eq!(audio_single.profile_string(), "AudioSingle");
assert_eq!(video_album.profile_string(), "VideoAlbum");
assert_eq!(video_single.profile_string(), "VideoSingle");
assert_eq!(mixed.profile_string(), "Mixed");
}
#[test]
fn test_schema_command_parsing() {
let command = SchemaCommand {
version: "4.3".to_string(),
profile: "AudioAlbum".to_string(),
output: Some("schema.json".to_string()),
typescript: true,
python: true,
examples: true,
strict: true,
};
let parsed_version = command.parse_version().unwrap();
let parsed_profile = command.parse_profile().unwrap();
assert!(matches!(parsed_version, DdexVersion::Ern43));
assert!(matches!(parsed_profile, MessageProfile::AudioAlbum));
}
}