use anyhow::Context;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::{env, fs, path::PathBuf};
#[derive(Deserialize, Serialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ReferenceResolutionConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_reference_pattern")]
pub output_pattern: String,
#[serde(default = "default_max_depth")]
pub max_depth: u32,
#[serde(default)]
pub output_overrides: std::collections::HashMap<String, Option<String>>,
}
impl Default for ReferenceResolutionConfig {
fn default() -> Self {
Self {
enabled: true,
output_pattern: default_reference_pattern(),
max_depth: default_max_depth(),
output_overrides: std::collections::HashMap::new(),
}
}
}
fn default_true() -> bool {
true
}
fn default_reference_pattern() -> String {
"references/{groupId}/{artifactId}/{version}.{ext}".to_string()
}
fn default_max_depth() -> u32 {
5
}
#[derive(Deserialize, Serialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct RepoConfig {
pub external_registries_file: Option<String>,
#[serde(default)]
pub registries: Vec<RegistryConfig>,
#[serde(default)]
pub dependencies: Vec<DependencyConfig>,
#[serde(default)]
pub reference_resolution: ReferenceResolutionConfig,
#[serde(default)]
pub publishes: Vec<PublishConfig>,
}
#[derive(Deserialize, Serialize, Debug, Clone, Default)]
#[serde(rename_all = "camelCase")]
pub struct RegistryConfig {
pub name: String,
pub url: String,
#[serde(default)]
pub auth: AuthConfig,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
#[serde(tag = "type")]
#[derive(Default)]
pub enum AuthConfig {
#[default]
None,
Basic {
username: String,
password_env: String,
},
Token {
token_env: String,
},
Bearer {
token_env: String,
},
}
#[derive(Deserialize, Serialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct DependencyConfig {
pub name: String,
#[serde(default)]
pub group_id: Option<String>,
#[serde(default)]
pub artifact_id: Option<String>,
pub version: String,
pub registry: String,
pub output_path: String,
#[serde(default)]
pub resolve_references: Option<bool>,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct PublishConfig {
pub name: String,
pub input_path: String,
pub version: String,
pub registry: String,
#[serde(default)]
pub group_id: Option<String>,
#[serde(default)]
pub artifact_id: Option<String>,
#[serde(default)]
pub r#type: Option<ArtifactType>,
#[serde(default)]
pub if_exists: IfExistsAction,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub labels: std::collections::HashMap<String, String>,
#[serde(default)]
pub references: Vec<ArtifactReference>,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
#[serde(rename_all = "kebab-case")]
pub enum ArtifactType {
Protobuf,
Avro,
JsonSchema,
Openapi,
AsyncApi,
GraphQL,
Xml,
Wsdl,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[derive(Default)]
pub enum IfExistsAction {
#[default]
Fail,
CreateVersion,
FindOrCreateVersion,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ArtifactReference {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub group_id: Option<String>,
#[serde(default)]
pub artifact_id: Option<String>,
pub version: String,
#[serde(default)]
pub name_alias: Option<String>,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct GlobalConfig {
#[serde(default)]
pub registries: Vec<RegistryConfig>,
}
impl RepoConfig {
pub fn merge_registries(&self, global: GlobalConfig) -> anyhow::Result<Vec<RegistryConfig>> {
let mut map = std::collections::HashMap::new();
for reg in global.registries {
map.insert(reg.name.clone(), reg);
}
if let Some(path) = &self.external_registries_file {
let contents = fs::read_to_string(path)
.with_context(|| format!("reading external registries from {path}"))?;
let ext: GlobalConfig = serde_yaml::from_str(&contents)?;
for reg in ext.registries {
map.insert(reg.name.clone(), reg);
}
}
for reg in &self.registries {
map.insert(reg.name.clone(), reg.clone());
}
Ok(map.into_values().collect())
}
}
impl PublishConfig {
pub fn resolved_group_id(&self) -> String {
self.group_id.clone().unwrap_or_else(|| {
if let Some((group, _)) = self.name.split_once('/') {
group.to_string()
} else {
"default".to_string()
}
})
}
pub fn resolved_artifact_id(&self) -> String {
self.artifact_id.clone().unwrap_or_else(|| {
if let Some((_, artifact)) = self.name.split_once('/') {
artifact.to_string()
} else {
self.name.clone()
}
})
}
pub fn resolved_content_type(&self) -> String {
if let Some(ref artifact_type) = self.r#type {
match artifact_type {
ArtifactType::Protobuf => "application/x-protobuf".to_string(),
ArtifactType::Avro => "application/json".to_string(),
ArtifactType::JsonSchema => "application/json".to_string(),
ArtifactType::Openapi => "application/json".to_string(),
ArtifactType::AsyncApi => "application/json".to_string(),
ArtifactType::GraphQL => "application/graphql".to_string(),
ArtifactType::Xml => "application/xml".to_string(),
ArtifactType::Wsdl => "application/xml".to_string(),
}
} else {
let path = std::path::Path::new(&self.input_path);
match path.extension().and_then(|e| e.to_str()) {
Some("proto") => "application/x-protobuf".to_string(),
Some("avsc") => "application/json".to_string(),
Some("json") => "application/json".to_string(),
Some("yaml") | Some("yml") => "application/yaml".to_string(),
Some("xml") => "application/xml".to_string(),
Some("graphql") | Some("gql") => "application/graphql".to_string(),
_ => "application/octet-stream".to_string(),
}
}
}
pub fn resolved_artifact_type(&self) -> String {
if let Some(ref artifact_type) = self.r#type {
match artifact_type {
ArtifactType::Protobuf => "PROTOBUF".to_string(),
ArtifactType::Avro => "AVRO".to_string(),
ArtifactType::JsonSchema => "JSON".to_string(),
ArtifactType::Openapi => "OPENAPI".to_string(),
ArtifactType::AsyncApi => "ASYNCAPI".to_string(),
ArtifactType::GraphQL => "GRAPHQL".to_string(),
ArtifactType::Xml => "XML".to_string(),
ArtifactType::Wsdl => "WSDL".to_string(),
}
} else {
let path = std::path::Path::new(&self.input_path);
match path.extension().and_then(|e| e.to_str()) {
Some("proto") => "PROTOBUF".to_string(),
Some("avsc") => "AVRO".to_string(),
Some("json") => "JSON".to_string(),
Some("yaml") | Some("yml") => "JSON".to_string(),
Some("xml") => "XML".to_string(),
Some("graphql") | Some("gql") => "GRAPHQL".to_string(),
_ => "JSON".to_string(),
}
}
}
}
impl DependencyConfig {
pub fn resolved_group_id(&self) -> String {
self.group_id.clone().unwrap_or_else(|| {
if let Some((group, _)) = self.name.split_once('/') {
group.to_string()
} else {
"default".to_string()
}
})
}
pub fn resolved_artifact_id(&self) -> String {
self.artifact_id.clone().unwrap_or_else(|| {
if let Some((_, artifact)) = self.name.split_once('/') {
artifact.to_string()
} else {
self.name.clone()
}
})
}
}
impl ArtifactReference {
pub fn validate_exact_version(&self) -> anyhow::Result<()> {
if self.version.contains('^')
|| self.version.contains('~')
|| self.version.contains('*')
|| self.version.contains('>')
|| self.version.contains('<')
{
anyhow::bail!(
"Reference version must be exact, got '{}'. Use exact version like '1.2.3'",
self.version
);
}
Ok(())
}
pub fn resolved_group_id(&self) -> String {
self.group_id.clone().unwrap_or_else(|| {
if let Some(name) = &self.name {
if let Some((group, _)) = name.split_once('/') {
group.to_string()
} else {
"default".to_string()
}
} else {
"default".to_string()
}
})
}
pub fn resolved_artifact_id(&self) -> String {
self.artifact_id.clone().unwrap_or_else(|| {
if let Some(name) = &self.name {
if let Some((_, artifact)) = name.split_once('/') {
artifact.to_string()
} else {
name.clone()
}
} else {
panic!("Either name or artifactId must be specified for reference")
}
})
}
}
pub fn load_repo_config(path: &Path) -> anyhow::Result<RepoConfig> {
let preprocessed_data = preprocess_config(path)?; let cfg: RepoConfig = serde_yaml::from_str(&preprocessed_data)?;
Ok(cfg)
}
pub fn load_global_config() -> anyhow::Result<GlobalConfig> {
let path = env::var("APICURIO_REGISTRIES_PATH")
.map(PathBuf::from)
.unwrap_or_else(|_| {
let mut p = dirs::config_dir().unwrap_or_else(|| PathBuf::from("."));
p.push("apicurio/registries.yaml");
p
});
if !path.exists() {
return Ok(GlobalConfig { registries: vec![] });
}
let data = fs::read_to_string(&path)
.with_context(|| format!("reading global registries {}", path.display()))?;
let cfg: GlobalConfig = serde_yaml::from_str(&data)?;
Ok(cfg)
}
pub fn save_global_config(cfg: &GlobalConfig) -> anyhow::Result<()> {
let path = env::var("APICURIO_REGISTRIES_PATH")
.map(PathBuf::from)
.unwrap_or_else(|_| {
let mut p = dirs::config_dir().unwrap_or_else(|| PathBuf::from("."));
p.push("apicurio/registries.yaml");
p
});
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let data = serde_yaml::to_string(cfg)?;
fs::write(&path, data)?;
println!("Saved global registries to {}", path.display());
Ok(())
}
pub fn expand_env_placeholders(input: &str) -> String {
let re = Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?:(:?[-+])([^}]*))?\}").unwrap();
re.replace_all(input, |caps: ®ex::Captures| {
let var_name = &caps[1];
let op = caps.get(2).map_or("", |m| m.as_str());
let val = caps.get(3).map_or("", |m| m.as_str());
let var = env::var(var_name).ok();
match (var.as_deref(), op) {
(Some(v), _) if op.is_empty() => v.to_string(), (Some(v), ":-") if !v.is_empty() => v.to_string(), (None, ":-") => val.to_string(),
(Some(v), "-") => {
if v.is_empty() {
val.to_string()
} else {
v.to_string()
}
} (None, "-") => val.to_string(),
(Some(v), ":+") if !v.is_empty() => val.to_string(), (Some(_), "+") => val.to_string(), _ => "".to_string(),
}
})
.to_string()
}
pub fn preprocess_config(path: &Path) -> anyhow::Result<String> {
let raw_data = fs::read_to_string(path)?;
Ok(expand_env_placeholders(&raw_data))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_dependency_smart_resolution() {
let dep_with_slash = DependencyConfig {
name: "com.example/user-service".to_string(),
group_id: None,
artifact_id: None,
version: "1.0.0".to_string(),
registry: "test".to_string(),
output_path: "out.proto".to_string(),
resolve_references: None,
};
assert_eq!(dep_with_slash.resolved_group_id(), "com.example");
assert_eq!(dep_with_slash.resolved_artifact_id(), "user-service");
let dep_simple = DependencyConfig {
name: "user-service".to_string(),
group_id: None,
artifact_id: None,
version: "1.0.0".to_string(),
registry: "test".to_string(),
output_path: "out.proto".to_string(),
resolve_references: None,
};
assert_eq!(dep_simple.resolved_group_id(), "default");
assert_eq!(dep_simple.resolved_artifact_id(), "user-service");
let dep_explicit = DependencyConfig {
name: "com.example/user-service".to_string(),
group_id: Some("custom.group".to_string()),
artifact_id: Some("custom-artifact".to_string()),
version: "1.0.0".to_string(),
registry: "test".to_string(),
output_path: "out.proto".to_string(),
resolve_references: None,
};
assert_eq!(dep_explicit.resolved_group_id(), "custom.group");
assert_eq!(dep_explicit.resolved_artifact_id(), "custom-artifact");
let dep_nprod = DependencyConfig {
name: "nprod/sp.frame.Frame".to_string(),
group_id: None,
artifact_id: None,
version: "4.3.1".to_string(),
registry: "nprod-apicurio".to_string(),
output_path: "protos/sp/frame/frame.proto".to_string(),
resolve_references: None,
};
assert_eq!(dep_nprod.resolved_group_id(), "nprod");
assert_eq!(dep_nprod.resolved_artifact_id(), "sp.frame.Frame");
}
#[test]
fn test_dependency_smart_resolution_edge_cases() {
let dep_multi_slash = DependencyConfig {
name: "com.example/nested/artifact".to_string(),
group_id: None,
artifact_id: None,
version: "1.0.0".to_string(),
registry: "test".to_string(),
output_path: "out.proto".to_string(),
resolve_references: None,
};
assert_eq!(dep_multi_slash.resolved_group_id(), "com.example");
assert_eq!(dep_multi_slash.resolved_artifact_id(), "nested/artifact");
let dep_empty_group = DependencyConfig {
name: "/artifact-only".to_string(),
group_id: None,
artifact_id: None,
version: "1.0.0".to_string(),
registry: "test".to_string(),
output_path: "out.proto".to_string(),
resolve_references: None,
};
assert_eq!(dep_empty_group.resolved_group_id(), "");
assert_eq!(dep_empty_group.resolved_artifact_id(), "artifact-only");
let dep_empty_artifact = DependencyConfig {
name: "group.only/".to_string(),
group_id: None,
artifact_id: None,
version: "1.0.0".to_string(),
registry: "test".to_string(),
output_path: "out.proto".to_string(),
resolve_references: None,
};
assert_eq!(dep_empty_artifact.resolved_group_id(), "group.only");
assert_eq!(dep_empty_artifact.resolved_artifact_id(), "");
let dep_partial_override = DependencyConfig {
name: "com.example/user-service".to_string(),
group_id: Some("override.group".to_string()),
artifact_id: None, version: "1.0.0".to_string(),
registry: "test".to_string(),
output_path: "out.proto".to_string(),
resolve_references: None,
};
assert_eq!(dep_partial_override.resolved_group_id(), "override.group");
assert_eq!(dep_partial_override.resolved_artifact_id(), "user-service");
let dep_partial_override2 = DependencyConfig {
name: "com.example/user-service".to_string(),
group_id: None, artifact_id: Some("override-artifact".to_string()),
version: "1.0.0".to_string(),
registry: "test".to_string(),
output_path: "out.proto".to_string(),
resolve_references: None,
};
assert_eq!(dep_partial_override2.resolved_group_id(), "com.example");
assert_eq!(
dep_partial_override2.resolved_artifact_id(),
"override-artifact"
);
}
#[test]
fn test_dependency_resolution_consistency_with_publish() {
let name = "com.example/user-service";
let dep = DependencyConfig {
name: name.to_string(),
group_id: None,
artifact_id: None,
version: "1.0.0".to_string(),
registry: "test".to_string(),
output_path: "out.proto".to_string(),
resolve_references: None,
};
let publish = PublishConfig {
name: name.to_string(),
input_path: "input.proto".to_string(),
version: "1.0.0".to_string(),
registry: "test".to_string(),
group_id: None,
artifact_id: None,
r#type: None,
if_exists: IfExistsAction::Fail,
description: None,
labels: std::collections::HashMap::new(),
references: Vec::new(),
};
assert_eq!(dep.resolved_group_id(), publish.resolved_group_id());
assert_eq!(dep.resolved_artifact_id(), publish.resolved_artifact_id());
}
}