1use crate::error::BuildError;
139use crate::presets::{DdexVersion, MessageProfile, PartnerPreset};
140use indexmap::IndexMap;
141use serde::{Deserialize, Serialize};
142use serde_json::{json, Value as JsonValue};
143
144mod generators;
145mod types;
146mod validation;
147
148pub use validation::{
150 SchemaValidator, ValidationConfig as SchemaValidationConfig,
151 ValidationResult as SchemaValidationResult,
152};
153
154#[derive(Debug, Clone)]
156pub struct SchemaGenerator {
157 version: DdexVersion,
159 profile: MessageProfile,
161 #[allow(dead_code)]
163 preset: Option<PartnerPreset>,
164 config: SchemaConfig,
166}
167
168#[derive(Debug, Clone)]
170pub struct SchemaConfig {
171 pub draft_version: SchemaDraft,
173 pub include_examples: bool,
175 pub include_descriptions: bool,
177 pub strict_validation: bool,
179 pub include_deprecated: bool,
181 pub version_conditionals: bool,
183}
184
185#[derive(Debug, Clone, Copy)]
187pub enum SchemaDraft {
188 Draft202012,
190 Draft07,
192}
193
194#[derive(Debug, Clone, Serialize, Deserialize)]
196pub struct JsonSchema {
197 #[serde(rename = "$schema")]
199 pub schema: String,
200 #[serde(rename = "$id", skip_serializing_if = "Option::is_none")]
202 pub id: Option<String>,
203 #[serde(skip_serializing_if = "Option::is_none")]
205 pub title: Option<String>,
206 #[serde(skip_serializing_if = "Option::is_none")]
208 pub description: Option<String>,
209 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
211 pub schema_type: Option<String>,
212 #[serde(skip_serializing_if = "Option::is_none")]
214 pub properties: Option<IndexMap<String, JsonSchema>>,
215 #[serde(skip_serializing_if = "Option::is_none")]
217 pub required: Option<Vec<String>>,
218 #[serde(
220 rename = "additionalProperties",
221 skip_serializing_if = "Option::is_none"
222 )]
223 pub additional_properties: Option<bool>,
224 #[serde(skip_serializing_if = "Option::is_none")]
226 pub items: Option<Box<JsonSchema>>,
227 #[serde(rename = "enum", skip_serializing_if = "Option::is_none")]
229 pub enum_values: Option<Vec<JsonValue>>,
230 #[serde(skip_serializing_if = "Option::is_none")]
232 pub pattern: Option<String>,
233 #[serde(skip_serializing_if = "Option::is_none")]
235 pub format: Option<String>,
236 #[serde(rename = "minLength", skip_serializing_if = "Option::is_none")]
238 pub min_length: Option<usize>,
239 #[serde(rename = "maxLength", skip_serializing_if = "Option::is_none")]
241 pub max_length: Option<usize>,
242 #[serde(skip_serializing_if = "Option::is_none")]
244 pub examples: Option<Vec<JsonValue>>,
245 #[serde(rename = "$defs", skip_serializing_if = "Option::is_none")]
247 pub definitions: Option<IndexMap<String, JsonSchema>>,
248 #[serde(rename = "$ref", skip_serializing_if = "Option::is_none")]
250 pub reference: Option<String>,
251 #[serde(rename = "allOf", skip_serializing_if = "Option::is_none")]
253 pub all_of: Option<Vec<JsonSchema>>,
254 #[serde(rename = "anyOf", skip_serializing_if = "Option::is_none")]
256 pub any_of: Option<Vec<JsonSchema>>,
257 #[serde(rename = "oneOf", skip_serializing_if = "Option::is_none")]
259 pub one_of: Option<Vec<JsonSchema>>,
260 #[serde(rename = "if", skip_serializing_if = "Option::is_none")]
262 pub if_schema: Option<Box<JsonSchema>>,
263 #[serde(rename = "then", skip_serializing_if = "Option::is_none")]
265 pub then_schema: Option<Box<JsonSchema>>,
266 #[serde(rename = "else", skip_serializing_if = "Option::is_none")]
268 pub else_schema: Option<Box<JsonSchema>>,
269 #[serde(flatten)]
271 pub annotations: IndexMap<String, JsonValue>,
272}
273
274#[derive(Debug, Clone)]
276pub struct SchemaGenerationResult {
277 pub schema: JsonSchema,
279 pub metadata: SchemaMetadata,
281 pub warnings: Vec<SchemaWarning>,
283}
284
285#[derive(Debug, Clone)]
287pub struct SchemaMetadata {
288 pub ddex_version: DdexVersion,
290 pub profile: MessageProfile,
292 pub draft_version: SchemaDraft,
294 pub generated_at: chrono::DateTime<chrono::Utc>,
296 pub property_count: usize,
298 pub required_count: usize,
300 pub complexity_score: f64,
302}
303
304#[derive(Debug, Clone)]
306pub struct SchemaWarning {
307 pub code: String,
309 pub message: String,
311 pub field_path: Option<String>,
313 pub suggestion: Option<String>,
315}
316
317impl Default for SchemaConfig {
318 fn default() -> Self {
319 Self {
320 draft_version: SchemaDraft::Draft202012,
321 include_examples: true,
322 include_descriptions: true,
323 strict_validation: true,
324 include_deprecated: false,
325 version_conditionals: true,
326 }
327 }
328}
329
330impl SchemaGenerator {
331 pub fn new(version: DdexVersion, profile: MessageProfile) -> Self {
333 Self {
334 version,
335 profile,
336 preset: None,
337 config: SchemaConfig::default(),
338 }
339 }
340
341 pub fn with_preset(
343 version: DdexVersion,
344 profile: MessageProfile,
345 preset: PartnerPreset,
346 ) -> Self {
347 Self {
348 version,
349 profile,
350 preset: Some(preset),
351 config: SchemaConfig::default(),
352 }
353 }
354
355 pub fn with_config(mut self, config: SchemaConfig) -> Self {
357 self.config = config;
358 self
359 }
360
361 pub fn generate_build_request_schema(&self) -> Result<SchemaGenerationResult, BuildError> {
363 let mut warnings = Vec::new();
364
365 let schema = self.build_request_schema(&mut warnings)?;
366
367 let metadata = SchemaMetadata {
368 ddex_version: self.version,
369 profile: self.profile,
370 draft_version: self.config.draft_version,
371 generated_at: chrono::Utc::now(),
372 property_count: self.count_properties(&schema),
373 required_count: schema.required.as_ref().map(|r| r.len()).unwrap_or(0),
374 complexity_score: self.calculate_complexity(&schema),
375 };
376
377 Ok(SchemaGenerationResult {
378 schema,
379 metadata,
380 warnings,
381 })
382 }
383
384 pub fn generate_flat_release_schema(&self) -> Result<SchemaGenerationResult, BuildError> {
386 let mut warnings = Vec::new();
387
388 let schema = self.flat_release_schema(&mut warnings)?;
389
390 let metadata = SchemaMetadata {
391 ddex_version: self.version,
392 profile: self.profile,
393 draft_version: self.config.draft_version,
394 generated_at: chrono::Utc::now(),
395 property_count: self.count_properties(&schema),
396 required_count: schema.required.as_ref().map(|r| r.len()).unwrap_or(0),
397 complexity_score: self.calculate_complexity(&schema),
398 };
399
400 Ok(SchemaGenerationResult {
401 schema,
402 metadata,
403 warnings,
404 })
405 }
406
407 pub fn generate_complete_schema(&self) -> Result<SchemaGenerationResult, BuildError> {
409 let mut warnings = Vec::new();
410 let mut definitions = IndexMap::new();
411
412 definitions.insert(
414 "BuildRequest".to_string(),
415 self.build_request_schema(&mut warnings)?,
416 );
417 definitions.insert(
418 "ReleaseRequest".to_string(),
419 self.release_request_schema(&mut warnings)?,
420 );
421 definitions.insert(
422 "TrackRequest".to_string(),
423 self.track_request_schema(&mut warnings)?,
424 );
425 definitions.insert(
426 "DealRequest".to_string(),
427 self.deal_request_schema(&mut warnings)?,
428 );
429 definitions.insert(
430 "MessageHeader".to_string(),
431 self.message_header_schema(&mut warnings)?,
432 );
433
434 definitions.extend(self.common_type_definitions(&mut warnings)?);
436
437 let schema = JsonSchema {
438 schema: self.schema_draft_url(),
439 id: Some("https://ddex.net/schema/ern/builder".to_string()),
440 title: Some(format!(
441 "DDEX Builder Schema - ERN {} {}",
442 self.version_string(),
443 self.profile_string()
444 )),
445 description: Some(format!(
446 "Complete JSON Schema for DDEX Builder structures targeting ERN {} with {} profile",
447 self.version_string(),
448 self.profile_string()
449 )),
450 schema_type: Some("object".to_string()),
451 definitions: Some(definitions),
452 additional_properties: Some(false),
453 ..Default::default()
454 };
455
456 let metadata = SchemaMetadata {
457 ddex_version: self.version,
458 profile: self.profile,
459 draft_version: self.config.draft_version,
460 generated_at: chrono::Utc::now(),
461 property_count: self.count_properties(&schema),
462 required_count: 0, complexity_score: self.calculate_complexity(&schema),
464 };
465
466 Ok(SchemaGenerationResult {
467 schema,
468 metadata,
469 warnings,
470 })
471 }
472
473 pub fn generate_typescript_types(&self, schema: &JsonSchema) -> Result<String, BuildError> {
475 let mut typescript = String::new();
476
477 typescript.push_str(&format!(
478 "// Generated TypeScript types for DDEX Builder - ERN {} {}\n",
479 self.version_string(),
480 self.profile_string()
481 ));
482 typescript.push_str("// Generated at: ");
483 typescript.push_str(&chrono::Utc::now().to_rfc3339());
484 typescript.push_str("\n\n");
485
486 if let Some(ref definitions) = schema.definitions {
487 for (name, def_schema) in definitions {
488 typescript.push_str(&self.schema_to_typescript(name, def_schema)?);
489 typescript.push_str("\n\n");
490 }
491 }
492
493 Ok(typescript)
494 }
495
496 pub fn generate_python_types(&self, schema: &JsonSchema) -> Result<String, BuildError> {
498 let mut python = String::new();
499
500 python.push_str(&format!(
501 "# Generated Python TypedDict types for DDEX Builder - ERN {} {}\n",
502 self.version_string(),
503 self.profile_string()
504 ));
505 python.push_str("# Generated at: ");
506 python.push_str(&chrono::Utc::now().to_rfc3339());
507 python.push_str("\n\n");
508 python.push_str("from typing import TypedDict, Optional, List, Union, Literal\nfrom datetime import datetime\n\n");
509
510 if let Some(ref definitions) = schema.definitions {
511 for (name, def_schema) in definitions {
512 python.push_str(&self.schema_to_python(name, def_schema)?);
513 python.push_str("\n\n");
514 }
515 }
516
517 Ok(python)
518 }
519
520 fn schema_draft_url(&self) -> String {
523 match self.config.draft_version {
524 SchemaDraft::Draft202012 => "https://json-schema.org/draft/2020-12/schema".to_string(),
525 SchemaDraft::Draft07 => "https://json-schema.org/draft-07/schema".to_string(),
526 }
527 }
528
529 fn version_string(&self) -> &str {
530 match self.version {
531 DdexVersion::Ern43 => "4.3",
532 DdexVersion::Ern42 => "4.2",
533 DdexVersion::Ern41 => "4.1",
534 DdexVersion::Ern382 => "3.8.2",
535 }
536 }
537
538 fn profile_string(&self) -> &str {
539 match self.profile {
540 MessageProfile::AudioAlbum => "AudioAlbum",
541 MessageProfile::AudioSingle => "AudioSingle",
542 MessageProfile::VideoAlbum => "VideoAlbum",
543 MessageProfile::VideoSingle => "VideoSingle",
544 MessageProfile::Mixed => "Mixed",
545 }
546 }
547
548 fn count_properties(&self, schema: &JsonSchema) -> usize {
549 let mut count = 0;
550
551 if let Some(ref properties) = schema.properties {
552 count += properties.len();
553 for (_, prop_schema) in properties {
554 count += self.count_properties(prop_schema);
555 }
556 }
557
558 if let Some(ref definitions) = schema.definitions {
559 for (_, def_schema) in definitions {
560 count += self.count_properties(def_schema);
561 }
562 }
563
564 count
565 }
566
567 fn calculate_complexity(&self, schema: &JsonSchema) -> f64 {
568 let mut complexity = 0.0;
569
570 if let Some(ref properties) = schema.properties {
572 complexity += properties.len() as f64;
573
574 for (_, prop_schema) in properties {
575 complexity += self.calculate_complexity(prop_schema) * 0.5;
576 }
577 }
578
579 if schema.all_of.is_some() {
581 complexity += 2.0;
582 }
583 if schema.any_of.is_some() {
584 complexity += 3.0;
585 }
586 if schema.one_of.is_some() {
587 complexity += 4.0;
588 }
589 if schema.if_schema.is_some() {
590 complexity += 5.0;
591 }
592 if schema.pattern.is_some() {
593 complexity += 1.0;
594 }
595 if schema.enum_values.is_some() {
596 complexity += 0.5;
597 }
598
599 complexity
600 }
601}
602
603impl Default for JsonSchema {
604 fn default() -> Self {
605 Self {
606 schema: String::new(),
607 id: None,
608 title: None,
609 description: None,
610 schema_type: None,
611 properties: None,
612 required: None,
613 additional_properties: None,
614 items: None,
615 enum_values: None,
616 pattern: None,
617 format: None,
618 min_length: None,
619 max_length: None,
620 examples: None,
621 definitions: None,
622 reference: None,
623 all_of: None,
624 any_of: None,
625 one_of: None,
626 if_schema: None,
627 then_schema: None,
628 else_schema: None,
629 annotations: IndexMap::new(),
630 }
631 }
632}
633
634impl std::fmt::Display for SchemaDraft {
635 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
636 match self {
637 SchemaDraft::Draft202012 => write!(f, "2020-12"),
638 SchemaDraft::Draft07 => write!(f, "draft-07"),
639 }
640 }
641}
642
643#[derive(Debug, Clone)]
645pub struct SchemaCommand {
646 pub version: String,
648 pub profile: String,
650 pub output: Option<String>,
652 pub typescript: bool,
654 pub python: bool,
656 pub examples: bool,
658 pub strict: bool,
660}
661
662impl SchemaCommand {
663 pub fn execute(&self) -> Result<(), BuildError> {
665 let version = self.parse_version()?;
666 let profile = self.parse_profile()?;
667
668 let config = SchemaConfig {
669 include_examples: self.examples,
670 strict_validation: self.strict,
671 ..Default::default()
672 };
673
674 let generator = SchemaGenerator::new(version, profile).with_config(config);
675 let result = generator.generate_complete_schema()?;
676
677 let schema_json = serde_json::to_string_pretty(&result.schema).map_err(|e| {
679 BuildError::InvalidFormat {
680 field: "schema".to_string(),
681 message: format!("Failed to serialize schema: {}", e),
682 }
683 })?;
684
685 if let Some(ref output_path) = self.output {
686 std::fs::write(output_path, &schema_json).map_err(|e| BuildError::InvalidFormat {
687 field: "output".to_string(),
688 message: format!("Failed to write schema: {}", e),
689 })?;
690 println!("Schema written to: {}", output_path);
691 } else {
692 println!("{}", schema_json);
693 }
694
695 if self.typescript {
697 let ts_types = generator.generate_typescript_types(&result.schema)?;
698 let ts_path = self
699 .output
700 .as_ref()
701 .map(|p| p.replace(".json", ".d.ts"))
702 .unwrap_or_else(|| "ddex-types.d.ts".to_string());
703
704 std::fs::write(&ts_path, ts_types).map_err(|e| BuildError::InvalidFormat {
705 field: "typescript".to_string(),
706 message: format!("Failed to write TypeScript types: {}", e),
707 })?;
708 println!("TypeScript types written to: {}", ts_path);
709 }
710
711 if self.python {
713 let py_types = generator.generate_python_types(&result.schema)?;
714 let py_path = self
715 .output
716 .as_ref()
717 .map(|p| p.replace(".json", ".py"))
718 .unwrap_or_else(|| "ddex_types.py".to_string());
719
720 std::fs::write(&py_path, py_types).map_err(|e| BuildError::InvalidFormat {
721 field: "python".to_string(),
722 message: format!("Failed to write Python types: {}", e),
723 })?;
724 println!("Python types written to: {}", py_path);
725 }
726
727 println!("\nSchema Generation Complete:");
729 println!(" DDEX Version: ERN {}", generator.version_string());
730 println!(" Profile: {}", generator.profile_string());
731 println!(" Properties: {}", result.metadata.property_count);
732 println!(" Required Fields: {}", result.metadata.required_count);
733 println!(
734 " Complexity Score: {:.1}",
735 result.metadata.complexity_score
736 );
737
738 if !result.warnings.is_empty() {
739 println!("\nWarnings:");
740 for warning in &result.warnings {
741 println!(" {}: {}", warning.code, warning.message);
742 if let Some(ref path) = warning.field_path {
743 println!(" Field: {}", path);
744 }
745 if let Some(ref suggestion) = warning.suggestion {
746 println!(" Suggestion: {}", suggestion);
747 }
748 }
749 }
750
751 Ok(())
752 }
753
754 fn parse_version(&self) -> Result<DdexVersion, BuildError> {
755 match self.version.as_str() {
756 "4.3" | "43" => Ok(DdexVersion::Ern43),
757 "4.2" | "42" => Ok(DdexVersion::Ern42),
758 "4.1" | "41" => Ok(DdexVersion::Ern41),
759 "3.8.2" | "382" => Ok(DdexVersion::Ern382),
760 _ => Err(BuildError::InvalidFormat {
761 field: "version".to_string(),
762 message: format!("Unsupported DDEX version: {}", self.version),
763 }),
764 }
765 }
766
767 fn parse_profile(&self) -> Result<MessageProfile, BuildError> {
768 match self.profile.to_lowercase().as_str() {
769 "audioalbum" | "audio-album" => Ok(MessageProfile::AudioAlbum),
770 "audiosingle" | "audio-single" => Ok(MessageProfile::AudioSingle),
771 "videoalbum" | "video-album" => Ok(MessageProfile::VideoAlbum),
772 "videosingle" | "video-single" => Ok(MessageProfile::VideoSingle),
773 "mixed" => Ok(MessageProfile::Mixed),
774 _ => Err(BuildError::InvalidFormat {
775 field: "profile".to_string(),
776 message: format!("Unsupported message profile: {}", self.profile),
777 }),
778 }
779 }
780}
781
782#[cfg(test)]
783mod tests {
784 use super::*;
785
786 #[test]
787 fn test_schema_generator_creation() {
788 let generator = SchemaGenerator::new(DdexVersion::Ern43, MessageProfile::AudioAlbum);
789 assert_eq!(generator.version, DdexVersion::Ern43);
790 assert_eq!(generator.profile, MessageProfile::AudioAlbum);
791 assert!(generator.preset.is_none());
792 }
793
794 #[test]
795 fn test_schema_config_defaults() {
796 let config = SchemaConfig::default();
797
798 assert!(matches!(config.draft_version, SchemaDraft::Draft202012));
799 assert!(config.include_examples);
800 assert!(config.include_descriptions);
801 assert!(config.strict_validation);
802 assert!(!config.include_deprecated);
803 assert!(config.version_conditionals);
804 }
805
806 #[test]
807 fn test_build_request_schema_generation() {
808 let generator = SchemaGenerator::new(DdexVersion::Ern43, MessageProfile::AudioAlbum);
809 let result = generator.generate_build_request_schema().unwrap();
810
811 assert!(result.schema.title.is_some());
812 assert!(result.schema.schema_type == Some("object".to_string()));
813 assert!(result.schema.properties.is_some());
814 assert!(result.schema.required.is_some());
815
816 let properties = result.schema.properties.unwrap();
817 assert!(properties.contains_key("header"));
818 assert!(properties.contains_key("releases"));
819
820 let required = result.schema.required.unwrap();
821 assert!(required.contains(&"header".to_string()));
822 assert!(required.contains(&"releases".to_string()));
823
824 assert!(result.metadata.property_count > 0);
826 assert!(result.metadata.complexity_score > 0.0);
827 }
828
829 #[test]
830 fn test_complete_schema_generation() {
831 let generator = SchemaGenerator::new(DdexVersion::Ern43, MessageProfile::AudioAlbum);
832 let result = generator.generate_complete_schema().unwrap();
833
834 assert!(result.schema.definitions.is_some());
835
836 let definitions = result.schema.definitions.unwrap();
837 assert!(definitions.contains_key("BuildRequest"));
838 assert!(definitions.contains_key("ReleaseRequest"));
839 assert!(definitions.contains_key("TrackRequest"));
840 assert!(definitions.contains_key("DealRequest"));
841 assert!(definitions.contains_key("MessageHeader"));
842 assert!(definitions.contains_key("LocalizedString"));
843 assert!(definitions.contains_key("Party"));
844 assert!(definitions.contains_key("DealTerms"));
845
846 assert_eq!(
848 result.schema.schema,
849 "https://json-schema.org/draft/2020-12/schema"
850 );
851 assert!(result.schema.id.is_some());
852 assert!(result.schema.title.is_some());
853 assert!(result.schema.description.is_some());
854 }
855
856 #[test]
857 fn test_version_strings() {
858 let generator_43 = SchemaGenerator::new(DdexVersion::Ern43, MessageProfile::AudioAlbum);
859 let generator_42 = SchemaGenerator::new(DdexVersion::Ern42, MessageProfile::AudioAlbum);
860 let generator_41 = SchemaGenerator::new(DdexVersion::Ern41, MessageProfile::AudioAlbum);
861
862 assert_eq!(generator_43.version_string(), "4.3");
863 assert_eq!(generator_42.version_string(), "4.2");
864 assert_eq!(generator_41.version_string(), "4.1");
865 }
866
867 #[test]
868 fn test_profile_strings() {
869 let audio_album = SchemaGenerator::new(DdexVersion::Ern43, MessageProfile::AudioAlbum);
870 let audio_single = SchemaGenerator::new(DdexVersion::Ern43, MessageProfile::AudioSingle);
871 let video_album = SchemaGenerator::new(DdexVersion::Ern43, MessageProfile::VideoAlbum);
872 let video_single = SchemaGenerator::new(DdexVersion::Ern43, MessageProfile::VideoSingle);
873 let mixed = SchemaGenerator::new(DdexVersion::Ern43, MessageProfile::Mixed);
874
875 assert_eq!(audio_album.profile_string(), "AudioAlbum");
876 assert_eq!(audio_single.profile_string(), "AudioSingle");
877 assert_eq!(video_album.profile_string(), "VideoAlbum");
878 assert_eq!(video_single.profile_string(), "VideoSingle");
879 assert_eq!(mixed.profile_string(), "Mixed");
880 }
881
882 #[test]
883 fn test_schema_command_parsing() {
884 let command = SchemaCommand {
885 version: "4.3".to_string(),
886 profile: "AudioAlbum".to_string(),
887 output: Some("schema.json".to_string()),
888 typescript: true,
889 python: true,
890 examples: true,
891 strict: true,
892 };
893
894 let parsed_version = command.parse_version().unwrap();
895 let parsed_profile = command.parse_profile().unwrap();
896
897 assert!(matches!(parsed_version, DdexVersion::Ern43));
898 assert!(matches!(parsed_profile, MessageProfile::AudioAlbum));
899 }
900}