1use anyhow::Context;
28use regex::Regex;
29use serde::{Deserialize, Serialize};
30use std::path::Path;
31use std::{env, fs, path::PathBuf};
32
33#[derive(Deserialize, Serialize, Debug, Clone)]
38#[serde(rename_all = "camelCase")]
39pub struct ReferenceResolutionConfig {
40 #[serde(default = "default_true")]
42 pub enabled: bool,
43 #[serde(default = "default_reference_pattern")]
47 pub output_pattern: String,
48 #[serde(default = "default_max_depth")]
50 pub max_depth: u32,
51 #[serde(default)]
55 pub output_overrides: std::collections::HashMap<String, Option<String>>,
56}
57
58impl Default for ReferenceResolutionConfig {
59 fn default() -> Self {
60 Self {
61 enabled: true,
62 output_pattern: default_reference_pattern(),
63 max_depth: default_max_depth(),
64 output_overrides: std::collections::HashMap::new(),
65 }
66 }
67}
68
69fn default_true() -> bool {
70 true
71}
72
73fn default_reference_pattern() -> String {
74 "references/{groupId}/{artifactId}/{version}.{ext}".to_string()
75}
76
77fn default_max_depth() -> u32 {
78 5
79}
80
81#[derive(Deserialize, Serialize, Debug)]
106#[serde(rename_all = "camelCase")]
107pub struct RepoConfig {
108 pub external_registries_file: Option<String>,
110 #[serde(default)]
112 pub registries: Vec<RegistryConfig>,
113 #[serde(default)]
115 pub dependencies: Vec<DependencyConfig>,
116 #[serde(default)]
118 pub reference_resolution: ReferenceResolutionConfig,
119 #[serde(default)]
121 pub publishes: Vec<PublishConfig>,
122}
123
124#[derive(Deserialize, Serialize, Debug, Clone, Default)]
129#[serde(rename_all = "camelCase")]
130pub struct RegistryConfig {
131 pub name: String,
133 pub url: String,
135 #[serde(default)]
137 pub auth: AuthConfig,
138}
139
140#[derive(Deserialize, Serialize, Debug, Clone)]
145#[serde(rename_all = "camelCase")]
146#[serde(tag = "type")]
147#[derive(Default)]
148pub enum AuthConfig {
149 #[default]
151 None,
152 Basic {
154 username: String,
156 password_env: String,
158 },
159 Token {
161 token_env: String,
163 },
164 Bearer {
166 token_env: String,
168 },
169}
170
171#[derive(Deserialize, Serialize, Debug, Clone)]
181#[serde(rename_all = "camelCase")]
182pub struct DependencyConfig {
183 pub name: String,
185 #[serde(default)]
187 pub group_id: Option<String>,
188 #[serde(default)]
190 pub artifact_id: Option<String>,
191 pub version: String,
193 pub registry: String,
195 pub output_path: String,
197 #[serde(default)]
199 pub resolve_references: Option<bool>,
200}
201
202#[derive(Deserialize, Serialize, Debug, Clone)]
207#[serde(rename_all = "camelCase")]
208pub struct PublishConfig {
209 pub name: String,
211 pub input_path: String,
213 pub version: String,
215 pub registry: String,
217
218 #[serde(default)]
221 pub group_id: Option<String>,
222 #[serde(default)]
224 pub artifact_id: Option<String>,
225 #[serde(default)]
227 pub r#type: Option<ArtifactType>,
228 #[serde(default)]
230 pub if_exists: IfExistsAction,
231 #[serde(default)]
233 pub description: Option<String>,
234 #[serde(default)]
236 pub labels: std::collections::HashMap<String, String>,
237 #[serde(default)]
239 pub references: Vec<ArtifactReference>,
240}
241
242#[derive(Deserialize, Serialize, Debug, Clone)]
247#[serde(rename_all = "kebab-case")]
248pub enum ArtifactType {
249 Protobuf,
251 Avro,
253 JsonSchema,
255 Openapi,
257 AsyncApi,
259 GraphQL,
261 Xml,
263 Wsdl,
265}
266
267#[derive(Deserialize, Serialize, Debug, Clone)]
269#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
270#[derive(Default)]
271pub enum IfExistsAction {
272 #[default]
274 Fail,
275 CreateVersion,
277 FindOrCreateVersion,
279}
280
281#[derive(Deserialize, Serialize, Debug, Clone)]
287#[serde(rename_all = "camelCase")]
288pub struct ArtifactReference {
289 #[serde(default)]
292 pub name: Option<String>,
293 #[serde(default)]
295 pub group_id: Option<String>,
296 #[serde(default)]
298 pub artifact_id: Option<String>,
299
300 pub version: String,
302
303 #[serde(default)]
305 pub name_alias: Option<String>,
306}
307
308#[derive(Deserialize, Serialize, Debug, Clone)]
313#[serde(rename_all = "camelCase")]
314pub struct GlobalConfig {
315 #[serde(default)]
317 pub registries: Vec<RegistryConfig>,
318}
319
320impl RepoConfig {
321 pub fn merge_registries(&self, global: GlobalConfig) -> anyhow::Result<Vec<RegistryConfig>> {
337 let mut map = std::collections::HashMap::new();
338 for reg in global.registries {
340 map.insert(reg.name.clone(), reg);
341 }
342 if let Some(path) = &self.external_registries_file {
344 let contents = fs::read_to_string(path)
345 .with_context(|| format!("reading external registries from {path}"))?;
346 let ext: GlobalConfig = serde_yaml::from_str(&contents)?;
347 for reg in ext.registries {
348 map.insert(reg.name.clone(), reg);
349 }
350 }
351 for reg in &self.registries {
353 map.insert(reg.name.clone(), reg.clone());
354 }
355 Ok(map.into_values().collect())
356 }
357}
358
359impl PublishConfig {
360 pub fn resolved_group_id(&self) -> String {
370 self.group_id.clone().unwrap_or_else(|| {
371 if let Some((group, _)) = self.name.split_once('/') {
372 group.to_string()
373 } else {
374 "default".to_string()
375 }
376 })
377 }
378
379 pub fn resolved_artifact_id(&self) -> String {
380 self.artifact_id.clone().unwrap_or_else(|| {
381 if let Some((_, artifact)) = self.name.split_once('/') {
382 artifact.to_string()
383 } else {
384 self.name.clone()
385 }
386 })
387 }
388
389 pub fn resolved_content_type(&self) -> String {
390 if let Some(ref artifact_type) = self.r#type {
391 match artifact_type {
392 ArtifactType::Protobuf => "application/x-protobuf".to_string(),
393 ArtifactType::Avro => "application/json".to_string(),
394 ArtifactType::JsonSchema => "application/json".to_string(),
395 ArtifactType::Openapi => "application/json".to_string(),
396 ArtifactType::AsyncApi => "application/json".to_string(),
397 ArtifactType::GraphQL => "application/graphql".to_string(),
398 ArtifactType::Xml => "application/xml".to_string(),
399 ArtifactType::Wsdl => "application/xml".to_string(),
400 }
401 } else {
402 let path = std::path::Path::new(&self.input_path);
404 match path.extension().and_then(|e| e.to_str()) {
405 Some("proto") => "application/x-protobuf".to_string(),
406 Some("avsc") => "application/json".to_string(),
407 Some("json") => "application/json".to_string(),
408 Some("yaml") | Some("yml") => "application/yaml".to_string(),
409 Some("xml") => "application/xml".to_string(),
410 Some("graphql") | Some("gql") => "application/graphql".to_string(),
411 _ => "application/octet-stream".to_string(),
412 }
413 }
414 }
415
416 pub fn resolved_artifact_type(&self) -> String {
417 if let Some(ref artifact_type) = self.r#type {
418 match artifact_type {
419 ArtifactType::Protobuf => "PROTOBUF".to_string(),
420 ArtifactType::Avro => "AVRO".to_string(),
421 ArtifactType::JsonSchema => "JSON".to_string(),
422 ArtifactType::Openapi => "OPENAPI".to_string(),
423 ArtifactType::AsyncApi => "ASYNCAPI".to_string(),
424 ArtifactType::GraphQL => "GRAPHQL".to_string(),
425 ArtifactType::Xml => "XML".to_string(),
426 ArtifactType::Wsdl => "WSDL".to_string(),
427 }
428 } else {
429 let path = std::path::Path::new(&self.input_path);
431 match path.extension().and_then(|e| e.to_str()) {
432 Some("proto") => "PROTOBUF".to_string(),
433 Some("avsc") => "AVRO".to_string(),
434 Some("json") => "JSON".to_string(),
435 Some("yaml") | Some("yml") => "JSON".to_string(),
436 Some("xml") => "XML".to_string(),
437 Some("graphql") | Some("gql") => "GRAPHQL".to_string(),
438 _ => "JSON".to_string(),
439 }
440 }
441 }
442}
443
444impl DependencyConfig {
445 pub fn resolved_group_id(&self) -> String {
455 self.group_id.clone().unwrap_or_else(|| {
456 if let Some((group, _)) = self.name.split_once('/') {
457 group.to_string()
458 } else {
459 "default".to_string()
460 }
461 })
462 }
463
464 pub fn resolved_artifact_id(&self) -> String {
474 self.artifact_id.clone().unwrap_or_else(|| {
475 if let Some((_, artifact)) = self.name.split_once('/') {
476 artifact.to_string()
477 } else {
478 self.name.clone()
479 }
480 })
481 }
482}
483
484impl ArtifactReference {
485 pub fn validate_exact_version(&self) -> anyhow::Result<()> {
487 if self.version.contains('^')
488 || self.version.contains('~')
489 || self.version.contains('*')
490 || self.version.contains('>')
491 || self.version.contains('<')
492 {
493 anyhow::bail!(
494 "Reference version must be exact, got '{}'. Use exact version like '1.2.3'",
495 self.version
496 );
497 }
498 Ok(())
499 }
500
501 pub fn resolved_group_id(&self) -> String {
502 self.group_id.clone().unwrap_or_else(|| {
503 if let Some(name) = &self.name {
504 if let Some((group, _)) = name.split_once('/') {
505 group.to_string()
506 } else {
507 "default".to_string()
508 }
509 } else {
510 "default".to_string()
511 }
512 })
513 }
514
515 pub fn resolved_artifact_id(&self) -> String {
516 self.artifact_id.clone().unwrap_or_else(|| {
517 if let Some(name) = &self.name {
518 if let Some((_, artifact)) = name.split_once('/') {
519 artifact.to_string()
520 } else {
521 name.clone()
522 }
523 } else {
524 panic!("Either name or artifactId must be specified for reference")
525 }
526 })
527 }
528}
529
530pub fn load_repo_config(path: &Path) -> anyhow::Result<RepoConfig> {
531 let preprocessed_data = preprocess_config(path)?; let cfg: RepoConfig = serde_yaml::from_str(&preprocessed_data)?;
533 Ok(cfg)
534}
535
536pub fn load_global_config() -> anyhow::Result<GlobalConfig> {
537 let path = env::var("APICURIO_REGISTRIES_PATH")
538 .map(PathBuf::from)
539 .unwrap_or_else(|_| {
540 let mut p = dirs::config_dir().unwrap_or_else(|| PathBuf::from("."));
541 p.push("apicurio/registries.yaml");
542 p
543 });
544 if !path.exists() {
545 return Ok(GlobalConfig { registries: vec![] });
546 }
547 let data = fs::read_to_string(&path)
548 .with_context(|| format!("reading global registries {}", path.display()))?;
549 let cfg: GlobalConfig = serde_yaml::from_str(&data)?;
550 Ok(cfg)
551}
552
553pub fn save_global_config(cfg: &GlobalConfig) -> anyhow::Result<()> {
554 let path = env::var("APICURIO_REGISTRIES_PATH")
556 .map(PathBuf::from)
557 .unwrap_or_else(|_| {
558 let mut p = dirs::config_dir().unwrap_or_else(|| PathBuf::from("."));
559 p.push("apicurio/registries.yaml");
560 p
561 });
562 if let Some(parent) = path.parent() {
563 fs::create_dir_all(parent)?;
564 }
565 let data = serde_yaml::to_string(cfg)?;
566 fs::write(&path, data)?;
567 println!("Saved global registries to {}", path.display());
568 Ok(())
569}
570
571pub fn expand_env_placeholders(input: &str) -> String {
572 let re = Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?:(:?[-+])([^}]*))?\}").unwrap();
573 re.replace_all(input, |caps: ®ex::Captures| {
574 let var_name = &caps[1];
575 let op = caps.get(2).map_or("", |m| m.as_str());
576 let val = caps.get(3).map_or("", |m| m.as_str());
577 let var = env::var(var_name).ok();
578
579 match (var.as_deref(), op) {
580 (Some(v), _) if op.is_empty() => v.to_string(), (Some(v), ":-") if !v.is_empty() => v.to_string(), (None, ":-") => val.to_string(),
583 (Some(v), "-") => {
584 if v.is_empty() {
585 val.to_string()
586 } else {
587 v.to_string()
588 }
589 } (None, "-") => val.to_string(),
591 (Some(v), ":+") if !v.is_empty() => val.to_string(), (Some(_), "+") => val.to_string(), _ => "".to_string(),
594 }
595 })
596 .to_string()
597}
598
599pub fn preprocess_config(path: &Path) -> anyhow::Result<String> {
600 let raw_data = fs::read_to_string(path)?;
601 Ok(expand_env_placeholders(&raw_data))
602}
603
604#[cfg(test)]
605mod tests {
606 use super::*;
607
608 #[test]
609 fn test_dependency_smart_resolution() {
610 let dep_with_slash = DependencyConfig {
612 name: "com.example/user-service".to_string(),
613 group_id: None,
614 artifact_id: None,
615 version: "1.0.0".to_string(),
616 registry: "test".to_string(),
617 output_path: "out.proto".to_string(),
618 resolve_references: None,
619 };
620
621 assert_eq!(dep_with_slash.resolved_group_id(), "com.example");
622 assert_eq!(dep_with_slash.resolved_artifact_id(), "user-service");
623
624 let dep_simple = DependencyConfig {
626 name: "user-service".to_string(),
627 group_id: None,
628 artifact_id: None,
629 version: "1.0.0".to_string(),
630 registry: "test".to_string(),
631 output_path: "out.proto".to_string(),
632 resolve_references: None,
633 };
634
635 assert_eq!(dep_simple.resolved_group_id(), "default");
636 assert_eq!(dep_simple.resolved_artifact_id(), "user-service");
637
638 let dep_explicit = DependencyConfig {
640 name: "com.example/user-service".to_string(),
641 group_id: Some("custom.group".to_string()),
642 artifact_id: Some("custom-artifact".to_string()),
643 version: "1.0.0".to_string(),
644 registry: "test".to_string(),
645 output_path: "out.proto".to_string(),
646 resolve_references: None,
647 };
648
649 assert_eq!(dep_explicit.resolved_group_id(), "custom.group");
650 assert_eq!(dep_explicit.resolved_artifact_id(), "custom-artifact");
651
652 let dep_nprod = DependencyConfig {
654 name: "nprod/sp.frame.Frame".to_string(),
655 group_id: None,
656 artifact_id: None,
657 version: "4.3.1".to_string(),
658 registry: "nprod-apicurio".to_string(),
659 output_path: "protos/sp/frame/frame.proto".to_string(),
660 resolve_references: None,
661 };
662
663 assert_eq!(dep_nprod.resolved_group_id(), "nprod");
664 assert_eq!(dep_nprod.resolved_artifact_id(), "sp.frame.Frame");
665 }
666
667 #[test]
668 fn test_dependency_smart_resolution_edge_cases() {
669 let dep_multi_slash = DependencyConfig {
671 name: "com.example/nested/artifact".to_string(),
672 group_id: None,
673 artifact_id: None,
674 version: "1.0.0".to_string(),
675 registry: "test".to_string(),
676 output_path: "out.proto".to_string(),
677 resolve_references: None,
678 };
679
680 assert_eq!(dep_multi_slash.resolved_group_id(), "com.example");
681 assert_eq!(dep_multi_slash.resolved_artifact_id(), "nested/artifact");
682
683 let dep_empty_group = DependencyConfig {
685 name: "/artifact-only".to_string(),
686 group_id: None,
687 artifact_id: None,
688 version: "1.0.0".to_string(),
689 registry: "test".to_string(),
690 output_path: "out.proto".to_string(),
691 resolve_references: None,
692 };
693
694 assert_eq!(dep_empty_group.resolved_group_id(), "");
695 assert_eq!(dep_empty_group.resolved_artifact_id(), "artifact-only");
696
697 let dep_empty_artifact = DependencyConfig {
699 name: "group.only/".to_string(),
700 group_id: None,
701 artifact_id: None,
702 version: "1.0.0".to_string(),
703 registry: "test".to_string(),
704 output_path: "out.proto".to_string(),
705 resolve_references: None,
706 };
707
708 assert_eq!(dep_empty_artifact.resolved_group_id(), "group.only");
709 assert_eq!(dep_empty_artifact.resolved_artifact_id(), "");
710
711 let dep_partial_override = DependencyConfig {
713 name: "com.example/user-service".to_string(),
714 group_id: Some("override.group".to_string()),
715 artifact_id: None, version: "1.0.0".to_string(),
717 registry: "test".to_string(),
718 output_path: "out.proto".to_string(),
719 resolve_references: None,
720 };
721
722 assert_eq!(dep_partial_override.resolved_group_id(), "override.group");
723 assert_eq!(dep_partial_override.resolved_artifact_id(), "user-service");
724
725 let dep_partial_override2 = DependencyConfig {
727 name: "com.example/user-service".to_string(),
728 group_id: None, artifact_id: Some("override-artifact".to_string()),
730 version: "1.0.0".to_string(),
731 registry: "test".to_string(),
732 output_path: "out.proto".to_string(),
733 resolve_references: None,
734 };
735
736 assert_eq!(dep_partial_override2.resolved_group_id(), "com.example");
737 assert_eq!(
738 dep_partial_override2.resolved_artifact_id(),
739 "override-artifact"
740 );
741 }
742
743 #[test]
744 fn test_dependency_resolution_consistency_with_publish() {
745 let name = "com.example/user-service";
747
748 let dep = DependencyConfig {
749 name: name.to_string(),
750 group_id: None,
751 artifact_id: None,
752 version: "1.0.0".to_string(),
753 registry: "test".to_string(),
754 output_path: "out.proto".to_string(),
755 resolve_references: None,
756 };
757
758 let publish = PublishConfig {
759 name: name.to_string(),
760 input_path: "input.proto".to_string(),
761 version: "1.0.0".to_string(),
762 registry: "test".to_string(),
763 group_id: None,
764 artifact_id: None,
765 r#type: None,
766 if_exists: IfExistsAction::Fail,
767 description: None,
768 labels: std::collections::HashMap::new(),
769 references: Vec::new(),
770 };
771
772 assert_eq!(dep.resolved_group_id(), publish.resolved_group_id());
773 assert_eq!(dep.resolved_artifact_id(), publish.resolved_artifact_id());
774 }
775}