Skip to main content

xbp_cli/commands/
openapi_archive.rs

1//! Archive generated OpenAPI contracts under global and project `.xbp` trees,
2//! and optionally upload them to xbp.app (R2-backed).
3
4use crate::commands::cli_session::{
5    post_openapi_generation_upload, resolve_cli_access_token, CliOpenApiArtifactUpload,
6    CliOpenApiGenerationUploadPayload,
7};
8use crate::config::{ensure_global_xbp_paths, ApiConfig};
9use crate::utils::{git_remote_url_from_metadata, parse_github_repo_from_remote_url};
10use chrono::Utc;
11use serde::{Deserialize, Serialize};
12use sha2::{Digest, Sha256};
13use std::fs;
14use std::path::{Path, PathBuf};
15use std::process::Command;
16
17/// One generated OpenAPI artifact ready to archive / upload.
18#[derive(Debug, Clone)]
19pub struct OpenApiArchiveArtifact {
20    pub path: PathBuf,
21    pub contents: String,
22    pub service_name: String,
23    pub service_version: String,
24    #[allow(dead_code)]
25    pub kind: OpenApiArtifactKind,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum OpenApiArtifactKind {
30    Service,
31    Aggregate,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
35#[serde(rename_all = "camelCase")]
36pub struct OpenApiGenerationManifest {
37    pub schema_version: u32,
38    pub updated_at: String,
39    pub repository_owner: String,
40    pub repository_name: String,
41    pub branch: String,
42    pub generations: Vec<OpenApiGenerationEntry>,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46#[serde(rename_all = "camelCase")]
47pub struct OpenApiGenerationEntry {
48    pub service_name: String,
49    pub service_version: String,
50    pub iteration: u32,
51    pub generated_at: String,
52    pub files: Vec<OpenApiGenerationFile>,
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub remote: Option<OpenApiGenerationRemote>,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
58#[serde(rename_all = "camelCase")]
59pub struct OpenApiGenerationFile {
60    pub filename: String,
61    pub format: String,
62    pub bytes: u64,
63    pub sha256: String,
64    pub relative_path: String,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
68#[serde(rename_all = "camelCase")]
69pub struct OpenApiGenerationRemote {
70    pub uploaded_at: String,
71    pub keys: Vec<String>,
72    #[serde(default, skip_serializing_if = "Vec::is_empty")]
73    pub public_urls: Vec<String>,
74}
75
76#[derive(Debug, Clone)]
77pub struct OpenApiArchiveResult {
78    pub global_dirs: Vec<PathBuf>,
79    pub project_dirs: Vec<PathBuf>,
80    pub uploaded: usize,
81    pub upload_skipped_reason: Option<String>,
82}
83
84/// Persist artifacts under:
85/// - `~/.xbp/openapi-generations/{owner}/{repo}/{branch}/{service}/{version}/{iteration}/`
86/// - `{project}/.xbp/openapi-generations/{service}/{version}/{iteration}/`
87/// and best-effort upload to xbp.app when logged in.
88pub async fn archive_and_upload_openapi_generations(
89    project_root: &Path,
90    artifacts: &[OpenApiArchiveArtifact],
91) -> Result<OpenApiArchiveResult, String> {
92    if artifacts.is_empty() {
93        return Ok(OpenApiArchiveResult {
94            global_dirs: Vec::new(),
95            project_dirs: Vec::new(),
96            uploaded: 0,
97            upload_skipped_reason: Some("no artifacts".into()),
98        });
99    }
100
101    let (owner, repo) = resolve_repo_identity(project_root);
102    let branch = resolve_git_branch(project_root).unwrap_or_else(|| "unknown".into());
103    let generated_at = Utc::now().to_rfc3339();
104
105    let mut global_dirs = Vec::new();
106    let mut project_dirs = Vec::new();
107    let mut upload_batches: Vec<(String, String, u32, Vec<OpenApiArchiveArtifact>)> = Vec::new();
108
109    // Group by service + version so iterations stay coherent across yaml/json.
110    let mut groups: std::collections::BTreeMap<(String, String), Vec<&OpenApiArchiveArtifact>> =
111        std::collections::BTreeMap::new();
112    for artifact in artifacts {
113        groups
114            .entry((
115                artifact.service_name.clone(),
116                artifact.service_version.clone(),
117            ))
118            .or_default()
119            .push(artifact);
120    }
121
122    for ((service_name, service_version), group) in groups {
123        let global_base = global_generation_base(&owner, &repo, &branch, &service_name, &service_version)?;
124        let project_base =
125            project_generation_base(project_root, &service_name, &service_version);
126        let iteration = next_iteration(&global_base)?
127            .max(next_iteration(&project_base)?);
128        let global_dir = global_base.join(iteration.to_string());
129        let project_dir = project_base.join(iteration.to_string());
130        fs::create_dir_all(&global_dir).map_err(|e| e.to_string())?;
131        fs::create_dir_all(&project_dir).map_err(|e| e.to_string())?;
132
133        let mut files = Vec::new();
134        let mut group_owned = Vec::new();
135        for artifact in group {
136            let filename = artifact
137                .path
138                .file_name()
139                .and_then(|n| n.to_str())
140                .unwrap_or("openapi.yaml")
141                .to_string();
142            let format = if filename.ends_with(".json") {
143                "json"
144            } else {
145                "yaml"
146            }
147            .to_string();
148            let sha = sha256_hex(artifact.contents.as_bytes());
149            let bytes = artifact.contents.len() as u64;
150
151            write_file(&global_dir.join(&filename), &artifact.contents)?;
152            write_file(&project_dir.join(&filename), &artifact.contents)?;
153
154            files.push(OpenApiGenerationFile {
155                filename: filename.clone(),
156                format,
157                bytes,
158                sha256: sha,
159                relative_path: format!(
160                    "{}/{}/{}",
161                    sanitize_segment(&service_name),
162                    sanitize_segment(&service_version),
163                    iteration
164                ),
165            });
166            group_owned.push(artifact.clone());
167        }
168
169        let entry = OpenApiGenerationEntry {
170            service_name: service_name.clone(),
171            service_version: service_version.clone(),
172            iteration,
173            generated_at: generated_at.clone(),
174            files,
175            remote: None,
176        };
177
178        upsert_manifest(
179            &global_manifest_path(&owner, &repo, &branch)?,
180            &owner,
181            &repo,
182            &branch,
183            entry.clone(),
184        )?;
185        upsert_manifest(
186            &project_manifest_path(project_root),
187            &owner,
188            &repo,
189            &branch,
190            entry,
191        )?;
192        write_readme(
193            &global_dir,
194            &owner,
195            &repo,
196            &branch,
197            &service_name,
198            &service_version,
199            iteration,
200        )?;
201        write_readme(
202            &project_dir,
203            &owner,
204            &repo,
205            &branch,
206            &service_name,
207            &service_version,
208            iteration,
209        )?;
210        write_tree_readme(
211            project_root.join(".xbp/openapi-generations"),
212            &owner,
213            &repo,
214            &branch,
215        )?;
216        write_tree_readme(
217            ensure_global_xbp_paths()?
218                .root_dir
219                .join("openapi-generations")
220                .join(sanitize_segment(&owner))
221                .join(sanitize_segment(&repo))
222                .join(sanitize_segment(&branch)),
223            &owner,
224            &repo,
225            &branch,
226        )?;
227
228        global_dirs.push(global_dir);
229        project_dirs.push(project_dir);
230        upload_batches.push((service_name, service_version, iteration, group_owned));
231    }
232
233    let mut uploaded = 0usize;
234    let mut upload_skipped_reason = None;
235    if resolve_cli_access_token().is_err() {
236        upload_skipped_reason = Some("not logged in (run `xbp login`)".into());
237    } else {
238        for (service_name, service_version, iteration, group) in upload_batches {
239            let artifacts_payload: Vec<CliOpenApiArtifactUpload> = group
240                .iter()
241                .map(|artifact| {
242                    let filename = artifact
243                        .path
244                        .file_name()
245                        .and_then(|n| n.to_str())
246                        .unwrap_or("openapi.yaml")
247                        .to_string();
248                    let format = if filename.ends_with(".json") {
249                        "json"
250                    } else {
251                        "yaml"
252                    }
253                    .to_string();
254                    CliOpenApiArtifactUpload {
255                        filename,
256                        format,
257                        content: artifact.contents.clone(),
258                        content_type: if artifact.path.extension().and_then(|e| e.to_str())
259                            == Some("json")
260                        {
261                            "application/json".into()
262                        } else {
263                            "application/yaml".into()
264                        },
265                        sha256: sha256_hex(artifact.contents.as_bytes()),
266                    }
267                })
268                .collect();
269
270            let payload = CliOpenApiGenerationUploadPayload {
271                repository_owner: owner.clone(),
272                repository_name: repo.clone(),
273                branch: branch.clone(),
274                service_name: service_name.clone(),
275                service_version: service_version.clone(),
276                iteration,
277                generated_at: generated_at.clone(),
278                project_path: Some(project_root.display().to_string()),
279                artifacts: artifacts_payload,
280            };
281
282            match post_openapi_generation_upload(&payload).await {
283                Ok(Some(response)) => {
284                    uploaded += response.uploaded;
285                    let remote = OpenApiGenerationRemote {
286                        uploaded_at: Utc::now().to_rfc3339(),
287                        keys: response.keys,
288                        public_urls: response.public_urls,
289                    };
290                    // Patch manifests with remote info (best-effort).
291                    let _ = patch_manifest_remote(
292                        &global_manifest_path(&owner, &repo, &branch)?,
293                        &service_name,
294                        &service_version,
295                        iteration,
296                        remote.clone(),
297                    );
298                    let _ = patch_manifest_remote(
299                        &project_manifest_path(project_root),
300                        &service_name,
301                        &service_version,
302                        iteration,
303                        remote,
304                    );
305                }
306                Ok(None) => {
307                    upload_skipped_reason =
308                        Some("xbp.app upload unavailable (route missing or unauthorized)".into());
309                }
310                Err(error) => {
311                    upload_skipped_reason = Some(error);
312                }
313            }
314        }
315    }
316
317    Ok(OpenApiArchiveResult {
318        global_dirs,
319        project_dirs,
320        uploaded,
321        upload_skipped_reason,
322    })
323}
324
325fn resolve_repo_identity(project_root: &Path) -> (String, String) {
326    git_remote_url_from_metadata(project_root, "origin")
327        .ok()
328        .flatten()
329        .and_then(|url| parse_github_repo_from_remote_url(&url))
330        .unwrap_or_else(|| {
331            let name = project_root
332                .file_name()
333                .and_then(|n| n.to_str())
334                .unwrap_or("local")
335                .to_string();
336            ("local".into(), name)
337        })
338}
339
340fn resolve_git_branch(project_root: &Path) -> Option<String> {
341    let output = Command::new("git")
342        .args(["rev-parse", "--abbrev-ref", "HEAD"])
343        .current_dir(project_root)
344        .output()
345        .ok()?;
346    if !output.status.success() {
347        return None;
348    }
349    let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
350    if branch.is_empty() || branch == "HEAD" {
351        None
352    } else {
353        Some(branch)
354    }
355}
356
357fn sanitize_segment(raw: &str) -> String {
358    let cleaned: String = raw
359        .chars()
360        .map(|c| {
361            if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
362                c
363            } else {
364                '-'
365            }
366        })
367        .collect();
368    let trimmed = cleaned.trim_matches('.').trim_matches('-');
369    if trimmed.is_empty() {
370        "unknown".into()
371    } else {
372        trimmed.to_string()
373    }
374}
375
376fn global_generation_base(
377    owner: &str,
378    repo: &str,
379    branch: &str,
380    service: &str,
381    version: &str,
382) -> Result<PathBuf, String> {
383    let paths = ensure_global_xbp_paths()?;
384    Ok(paths
385        .root_dir
386        .join("openapi-generations")
387        .join(sanitize_segment(owner))
388        .join(sanitize_segment(repo))
389        .join(sanitize_segment(branch))
390        .join(sanitize_segment(service))
391        .join(sanitize_segment(version)))
392}
393
394fn project_generation_base(project_root: &Path, service: &str, version: &str) -> PathBuf {
395    project_root
396        .join(".xbp")
397        .join("openapi-generations")
398        .join(sanitize_segment(service))
399        .join(sanitize_segment(version))
400}
401
402fn global_manifest_path(owner: &str, repo: &str, branch: &str) -> Result<PathBuf, String> {
403    let paths = ensure_global_xbp_paths()?;
404    Ok(paths
405        .root_dir
406        .join("openapi-generations")
407        .join(sanitize_segment(owner))
408        .join(sanitize_segment(repo))
409        .join(sanitize_segment(branch))
410        .join("manifest.json"))
411}
412
413fn project_manifest_path(project_root: &Path) -> PathBuf {
414    project_root
415        .join(".xbp")
416        .join("openapi-generations")
417        .join("manifest.json")
418}
419
420fn next_iteration(base: &Path) -> Result<u32, String> {
421    if !base.exists() {
422        return Ok(1);
423    }
424    let mut max = 0u32;
425    for entry in fs::read_dir(base).map_err(|e| e.to_string())? {
426        let entry = entry.map_err(|e| e.to_string())?;
427        if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
428            continue;
429        }
430        if let Some(n) = entry
431            .file_name()
432            .to_str()
433            .and_then(|s| s.parse::<u32>().ok())
434        {
435            max = max.max(n);
436        }
437    }
438    Ok(max.saturating_add(1).max(1))
439}
440
441fn write_file(path: &Path, contents: &str) -> Result<(), String> {
442    if let Some(parent) = path.parent() {
443        fs::create_dir_all(parent).map_err(|e| e.to_string())?;
444    }
445    fs::write(path, contents).map_err(|e| format!("failed to write {}: {e}", path.display()))
446}
447
448fn sha256_hex(bytes: &[u8]) -> String {
449    let mut hasher = Sha256::new();
450    hasher.update(bytes);
451    format!("{:x}", hasher.finalize())
452}
453
454fn upsert_manifest(
455    path: &Path,
456    owner: &str,
457    repo: &str,
458    branch: &str,
459    entry: OpenApiGenerationEntry,
460) -> Result<(), String> {
461    let mut manifest = if path.exists() {
462        let raw = fs::read_to_string(path).map_err(|e| e.to_string())?;
463        serde_json::from_str::<OpenApiGenerationManifest>(&raw).unwrap_or(OpenApiGenerationManifest {
464            schema_version: 1,
465            updated_at: Utc::now().to_rfc3339(),
466            repository_owner: owner.to_string(),
467            repository_name: repo.to_string(),
468            branch: branch.to_string(),
469            generations: Vec::new(),
470        })
471    } else {
472        OpenApiGenerationManifest {
473            schema_version: 1,
474            updated_at: Utc::now().to_rfc3339(),
475            repository_owner: owner.to_string(),
476            repository_name: repo.to_string(),
477            branch: branch.to_string(),
478            generations: Vec::new(),
479        }
480    };
481    manifest.updated_at = Utc::now().to_rfc3339();
482    manifest.repository_owner = owner.to_string();
483    manifest.repository_name = repo.to_string();
484    manifest.branch = branch.to_string();
485    manifest.generations.insert(0, entry);
486    // Keep the last 100 generations in the manifest for readability.
487    if manifest.generations.len() > 100 {
488        manifest.generations.truncate(100);
489    }
490    let pretty = serde_json::to_string_pretty(&manifest).map_err(|e| e.to_string())?;
491    write_file(path, &format!("{pretty}\n"))
492}
493
494fn patch_manifest_remote(
495    path: &Path,
496    service: &str,
497    version: &str,
498    iteration: u32,
499    remote: OpenApiGenerationRemote,
500) -> Result<(), String> {
501    if !path.exists() {
502        return Ok(());
503    }
504    let raw = fs::read_to_string(path).map_err(|e| e.to_string())?;
505    let mut manifest: OpenApiGenerationManifest =
506        serde_json::from_str(&raw).map_err(|e| e.to_string())?;
507    for entry in &mut manifest.generations {
508        if entry.service_name == service
509            && entry.service_version == version
510            && entry.iteration == iteration
511        {
512            entry.remote = Some(remote);
513            break;
514        }
515    }
516    manifest.updated_at = Utc::now().to_rfc3339();
517    let pretty = serde_json::to_string_pretty(&manifest).map_err(|e| e.to_string())?;
518    write_file(path, &format!("{pretty}\n"))
519}
520
521fn write_readme(
522    dir: &Path,
523    owner: &str,
524    repo: &str,
525    branch: &str,
526    service: &str,
527    version: &str,
528    iteration: u32,
529) -> Result<(), String> {
530    let body = format!(
531        "# OpenAPI generation\n\n\
532         | Field | Value |\n\
533         | --- | --- |\n\
534         | Repository | `{owner}/{repo}` |\n\
535         | Branch | `{branch}` |\n\
536         | Service | `{service}` |\n\
537         | Version | `{version}` |\n\
538         | Iteration | `{iteration}` |\n\
539         | Generated | `{}` |\n\n\
540         Files in this directory were produced by `xbp generate openapi`.\n\
541         See `manifest.json` one level up (project) or at the branch root (global) for the full index.\n",
542        Utc::now().to_rfc3339()
543    );
544    write_file(&dir.join("README.md"), &body)
545}
546
547fn write_tree_readme(dir: PathBuf, owner: &str, repo: &str, branch: &str) -> Result<(), String> {
548    fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
549    let body = format!(
550        "# OpenAPI generations\n\n\
551         Self-updating archive for `{owner}/{repo}` on `{branch}`.\n\n\
552         Layout:\n\n\
553         ```text\n\
554         {{service}}/{{version}}/{{iteration}}/openapi.{{yaml,json}}\n\
555         manifest.json\n\
556         ```\n\n\
557         Regenerated by `xbp generate openapi`. Iterations increment automatically when the same\n\
558         service version is generated again.\n\n\
559         Last updated: {}\n",
560        Utc::now().to_rfc3339()
561    );
562    write_file(&dir.join("README.md"), &body)
563}
564
565// Keep ApiConfig import used when feature builds expand upload endpoints.
566#[allow(dead_code)]
567fn _api_hint() -> String {
568    ApiConfig::load().cli_openapi_upload_endpoint()
569}
570
571// Re-export for unit tests of segment sanitization without full archive.
572#[cfg(test)]
573mod tests {
574    use super::*;
575
576    #[test]
577    fn sanitizes_path_segments() {
578        assert_eq!(sanitize_segment("athena-auth"), "athena-auth");
579        assert_eq!(sanitize_segment("feat/foo"), "feat-foo");
580        assert_eq!(sanitize_segment("../x"), "x");
581        assert_eq!(sanitize_segment(""), "unknown");
582    }
583
584    #[test]
585    fn iteration_starts_at_one() {
586        let dir = std::env::temp_dir().join(format!(
587            "xbp-openapi-iter-{}",
588            Utc::now().timestamp_nanos_opt().unwrap_or(0)
589        ));
590        let _ = fs::remove_dir_all(&dir);
591        assert_eq!(next_iteration(&dir).unwrap(), 1);
592        fs::create_dir_all(dir.join("1")).unwrap();
593        fs::create_dir_all(dir.join("2")).unwrap();
594        assert_eq!(next_iteration(&dir).unwrap(), 3);
595        let _ = fs::remove_dir_all(&dir);
596    }
597}