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)]
59#[serde(rename_all = "camelCase")]
60pub struct RepoConfig {
61 pub external_registries_file: Option<String>,
63 #[serde(default)]
65 pub registries: Vec<RegistryConfig>,
66 #[serde(default)]
68 pub dependencies: Vec<DependencyConfig>,
69 #[serde(default)]
71 pub publishes: Vec<PublishConfig>,
72}
73
74#[derive(Deserialize, Serialize, Debug, Clone, Default)]
79#[serde(rename_all = "camelCase")]
80pub struct RegistryConfig {
81 pub name: String,
83 pub url: String,
85 #[serde(default)]
87 pub auth: AuthConfig,
88}
89
90#[derive(Deserialize, Serialize, Debug, Clone)]
95#[serde(rename_all = "camelCase")]
96#[serde(tag = "type")]
97#[derive(Default)]
98pub enum AuthConfig {
99 #[default]
101 None,
102 Basic {
104 username: String,
106 password_env: String,
108 },
109 Token {
111 token_env: String,
113 },
114 Bearer {
116 token_env: String,
118 },
119}
120
121#[derive(Deserialize, Serialize, Debug, Clone)]
126#[serde(rename_all = "camelCase")]
127pub struct DependencyConfig {
128 pub name: String,
130 pub group_id: String,
132 pub artifact_id: String,
134 pub version: String,
136 pub registry: String,
138 pub output_path: String,
140}
141
142#[derive(Deserialize, Serialize, Debug, Clone)]
147#[serde(rename_all = "camelCase")]
148pub struct PublishConfig {
149 pub name: String,
151 pub input_path: String,
153 pub version: String,
155 pub registry: String,
157
158 #[serde(default)]
161 pub group_id: Option<String>,
162 #[serde(default)]
164 pub artifact_id: Option<String>,
165 #[serde(default)]
167 pub r#type: Option<ArtifactType>,
168 #[serde(default)]
170 pub if_exists: IfExistsAction,
171 #[serde(default)]
173 pub description: Option<String>,
174 #[serde(default)]
176 pub labels: std::collections::HashMap<String, String>,
177 #[serde(default)]
179 pub references: Vec<ArtifactReference>,
180}
181
182#[derive(Deserialize, Serialize, Debug, Clone)]
187#[serde(rename_all = "kebab-case")]
188pub enum ArtifactType {
189 Protobuf,
191 Avro,
193 JsonSchema,
195 Openapi,
197 AsyncApi,
199 GraphQL,
201 Xml,
203 Wsdl,
205}
206
207#[derive(Deserialize, Serialize, Debug, Clone)]
209#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
210#[derive(Default)]
211pub enum IfExistsAction {
212 #[default]
214 Fail,
215 CreateVersion,
217 FindOrCreateVersion,
219}
220
221#[derive(Deserialize, Serialize, Debug, Clone)]
227#[serde(rename_all = "camelCase")]
228pub struct ArtifactReference {
229 #[serde(default)]
232 pub name: Option<String>,
233 #[serde(default)]
235 pub group_id: Option<String>,
236 #[serde(default)]
238 pub artifact_id: Option<String>,
239
240 pub version: String,
242
243 #[serde(default)]
245 pub name_alias: Option<String>,
246}
247
248#[derive(Deserialize, Serialize, Debug, Clone)]
253#[serde(rename_all = "camelCase")]
254pub struct GlobalConfig {
255 #[serde(default)]
257 pub registries: Vec<RegistryConfig>,
258}
259
260impl RepoConfig {
261 pub fn merge_registries(&self, global: GlobalConfig) -> anyhow::Result<Vec<RegistryConfig>> {
277 let mut map = std::collections::HashMap::new();
278 for reg in global.registries {
280 map.insert(reg.name.clone(), reg);
281 }
282 if let Some(path) = &self.external_registries_file {
284 let contents = fs::read_to_string(path)
285 .with_context(|| format!("reading external registries from {path}"))?;
286 let ext: GlobalConfig = serde_yaml::from_str(&contents)?;
287 for reg in ext.registries {
288 map.insert(reg.name.clone(), reg);
289 }
290 }
291 for reg in &self.registries {
293 map.insert(reg.name.clone(), reg.clone());
294 }
295 Ok(map.into_values().collect())
296 }
297}
298
299impl PublishConfig {
300 pub fn resolved_group_id(&self) -> String {
310 self.group_id.clone().unwrap_or_else(|| {
311 if let Some((group, _)) = self.name.split_once('/') {
312 group.to_string()
313 } else {
314 "default".to_string()
315 }
316 })
317 }
318
319 pub fn resolved_artifact_id(&self) -> String {
320 self.artifact_id.clone().unwrap_or_else(|| {
321 if let Some((_, artifact)) = self.name.split_once('/') {
322 artifact.to_string()
323 } else {
324 self.name.clone()
325 }
326 })
327 }
328
329 pub fn resolved_content_type(&self) -> String {
330 if let Some(ref artifact_type) = self.r#type {
331 match artifact_type {
332 ArtifactType::Protobuf => "application/x-protobuf".to_string(),
333 ArtifactType::Avro => "application/json".to_string(),
334 ArtifactType::JsonSchema => "application/json".to_string(),
335 ArtifactType::Openapi => "application/json".to_string(),
336 ArtifactType::AsyncApi => "application/json".to_string(),
337 ArtifactType::GraphQL => "application/graphql".to_string(),
338 ArtifactType::Xml => "application/xml".to_string(),
339 ArtifactType::Wsdl => "application/xml".to_string(),
340 }
341 } else {
342 let path = std::path::Path::new(&self.input_path);
344 match path.extension().and_then(|e| e.to_str()) {
345 Some("proto") => "application/x-protobuf".to_string(),
346 Some("avsc") => "application/json".to_string(),
347 Some("json") => "application/json".to_string(),
348 Some("yaml") | Some("yml") => "application/yaml".to_string(),
349 Some("xml") => "application/xml".to_string(),
350 Some("graphql") | Some("gql") => "application/graphql".to_string(),
351 _ => "application/octet-stream".to_string(),
352 }
353 }
354 }
355
356 pub fn resolved_artifact_type(&self) -> String {
357 if let Some(ref artifact_type) = self.r#type {
358 match artifact_type {
359 ArtifactType::Protobuf => "PROTOBUF".to_string(),
360 ArtifactType::Avro => "AVRO".to_string(),
361 ArtifactType::JsonSchema => "JSON".to_string(),
362 ArtifactType::Openapi => "OPENAPI".to_string(),
363 ArtifactType::AsyncApi => "ASYNCAPI".to_string(),
364 ArtifactType::GraphQL => "GRAPHQL".to_string(),
365 ArtifactType::Xml => "XML".to_string(),
366 ArtifactType::Wsdl => "WSDL".to_string(),
367 }
368 } else {
369 let path = std::path::Path::new(&self.input_path);
371 match path.extension().and_then(|e| e.to_str()) {
372 Some("proto") => "PROTOBUF".to_string(),
373 Some("avsc") => "AVRO".to_string(),
374 Some("json") => "JSON".to_string(),
375 Some("yaml") | Some("yml") => "JSON".to_string(),
376 Some("xml") => "XML".to_string(),
377 Some("graphql") | Some("gql") => "GRAPHQL".to_string(),
378 _ => "JSON".to_string(),
379 }
380 }
381 }
382}
383
384impl ArtifactReference {
385 pub fn validate_exact_version(&self) -> anyhow::Result<()> {
387 if self.version.contains('^')
388 || self.version.contains('~')
389 || self.version.contains('*')
390 || self.version.contains('>')
391 || self.version.contains('<')
392 {
393 anyhow::bail!(
394 "Reference version must be exact, got '{}'. Use exact version like '1.2.3'",
395 self.version
396 );
397 }
398 Ok(())
399 }
400
401 pub fn resolved_group_id(&self) -> String {
402 self.group_id.clone().unwrap_or_else(|| {
403 if let Some(name) = &self.name {
404 if let Some((group, _)) = name.split_once('/') {
405 group.to_string()
406 } else {
407 "default".to_string()
408 }
409 } else {
410 "default".to_string()
411 }
412 })
413 }
414
415 pub fn resolved_artifact_id(&self) -> String {
416 self.artifact_id.clone().unwrap_or_else(|| {
417 if let Some(name) = &self.name {
418 if let Some((_, artifact)) = name.split_once('/') {
419 artifact.to_string()
420 } else {
421 name.clone()
422 }
423 } else {
424 panic!("Either name or artifactId must be specified for reference")
425 }
426 })
427 }
428}
429
430pub fn load_repo_config(path: &Path) -> anyhow::Result<RepoConfig> {
431 let preprocessed_data = preprocess_config(path)?; let cfg: RepoConfig = serde_yaml::from_str(&preprocessed_data)?;
433 Ok(cfg)
434}
435
436pub fn load_global_config() -> anyhow::Result<GlobalConfig> {
437 let path = env::var("APICURIO_REGISTRIES_PATH")
438 .map(PathBuf::from)
439 .unwrap_or_else(|_| {
440 let mut p = dirs::config_dir().unwrap_or_else(|| PathBuf::from("."));
441 p.push("apicurio/registries.yaml");
442 p
443 });
444 if !path.exists() {
445 return Ok(GlobalConfig { registries: vec![] });
446 }
447 let data = fs::read_to_string(&path)
448 .with_context(|| format!("reading global registries {}", path.display()))?;
449 let cfg: GlobalConfig = serde_yaml::from_str(&data)?;
450 Ok(cfg)
451}
452
453pub fn save_global_config(cfg: &GlobalConfig) -> anyhow::Result<()> {
454 let path = env::var("APICURIO_REGISTRIES_PATH")
456 .map(PathBuf::from)
457 .unwrap_or_else(|_| {
458 let mut p = dirs::config_dir().unwrap_or_else(|| PathBuf::from("."));
459 p.push("apicurio/registries.yaml");
460 p
461 });
462 if let Some(parent) = path.parent() {
463 fs::create_dir_all(parent)?;
464 }
465 let data = serde_yaml::to_string(cfg)?;
466 fs::write(&path, data)?;
467 println!("Saved global registries to {}", path.display());
468 Ok(())
469}
470
471pub fn expand_env_placeholders(input: &str) -> String {
472 let re = Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?:(:?[-+])([^}]*))?\}").unwrap();
473 re.replace_all(input, |caps: ®ex::Captures| {
474 let var_name = &caps[1];
475 let op = caps.get(2).map_or("", |m| m.as_str());
476 let val = caps.get(3).map_or("", |m| m.as_str());
477 let var = env::var(var_name).ok();
478
479 match (var.as_deref(), op) {
480 (Some(v), _) if op.is_empty() => v.to_string(), (Some(v), ":-") if !v.is_empty() => v.to_string(), (None, ":-") => val.to_string(),
483 (Some(v), "-") => {
484 if v.is_empty() {
485 val.to_string()
486 } else {
487 v.to_string()
488 }
489 } (None, "-") => val.to_string(),
491 (Some(v), ":+") if !v.is_empty() => val.to_string(), (Some(_), "+") => val.to_string(), _ => "".to_string(),
494 }
495 })
496 .to_string()
497}
498
499pub fn preprocess_config(path: &Path) -> anyhow::Result<String> {
500 let raw_data = fs::read_to_string(path)?;
501 Ok(expand_env_placeholders(&raw_data))
502}