Skip to content

Commit a02081f

Browse files
authored
Merge pull request #126 from 23prime/feature/121-space-upload-attachment
feat: add bl space upload-attachment command
2 parents 89bbd1e + 4543aab commit a02081f

12 files changed

Lines changed: 454 additions & 10 deletions

File tree

Cargo.lock

Lines changed: 23 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ path = "src/main.rs"
1313
anyhow = "1"
1414
clap = { version = "4", features = ["derive"] }
1515
dirs = "6"
16-
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
16+
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "multipart", "rustls-tls"] }
1717
serde = { version = "1", features = ["derive"] }
1818
serde_json = "1"
1919
toml = "1.0"

src/api/mod.rs

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ use pull_request::{
6262
use rate_limit::RateLimit;
6363
use resolution::Resolution;
6464
use shared_file::SharedFile;
65-
use space::Space;
65+
use space::{Space, SpaceAttachment};
6666
use space_notification::SpaceNotification;
6767
use team::Team;
6868
use user::{RecentlyViewedIssue, RecentlyViewedProject, RecentlyViewedWiki, Star, StarCount, User};
@@ -91,6 +91,9 @@ pub trait BacklogApi {
9191
fn download_space_image(&self) -> Result<(Vec<u8>, String)> {
9292
unimplemented!()
9393
}
94+
fn upload_space_attachment(&self, _file_path: &std::path::Path) -> Result<SpaceAttachment> {
95+
unimplemented!()
96+
}
9497
fn get_rate_limit(&self) -> Result<RateLimit> {
9598
unimplemented!()
9699
}
@@ -595,6 +598,9 @@ pub trait BacklogApi {
595598
fn count_user_stars(&self, _user_id: u64, _params: &[(String, String)]) -> Result<StarCount> {
596599
unimplemented!()
597600
}
601+
fn download_user_icon(&self, _user_id: u64) -> Result<(Vec<u8>, String)> {
602+
unimplemented!()
603+
}
598604
fn add_star(&self, _params: &[(String, String)]) -> Result<()> {
599605
unimplemented!()
600606
}
@@ -774,6 +780,10 @@ impl BacklogApi for BacklogClient {
774780
self.download_space_image()
775781
}
776782

783+
fn upload_space_attachment(&self, file_path: &std::path::Path) -> Result<SpaceAttachment> {
784+
self.upload_space_attachment(file_path)
785+
}
786+
777787
fn get_rate_limit(&self) -> Result<RateLimit> {
778788
self.get_rate_limit()
779789
}
@@ -1390,6 +1400,10 @@ impl BacklogApi for BacklogClient {
13901400
self.count_user_stars(user_id, params)
13911401
}
13921402

1403+
fn download_user_icon(&self, user_id: u64) -> Result<(Vec<u8>, String)> {
1404+
self.download_user_icon(user_id)
1405+
}
1406+
13931407
fn add_star(&self, params: &[(String, String)]) -> Result<()> {
13941408
self.add_star(params)
13951409
}
@@ -1745,6 +1759,26 @@ impl BacklogClient {
17451759
})
17461760
}
17471761

1762+
/// Post a multipart/form-data request.
1763+
///
1764+
/// The provided factory closure is called on each attempt to build a fresh
1765+
/// `reqwest::blocking::multipart::Form`, allowing this method to reuse the
1766+
/// standard [`Self::execute`] retry logic (including retry-on-401).
1767+
pub fn post_multipart<F>(&self, path: &str, form_factory: F) -> Result<serde_json::Value>
1768+
where
1769+
F: Fn() -> reqwest::blocking::multipart::Form,
1770+
{
1771+
let url = format!("{}{}", self.base_url, path);
1772+
self.execute(|| {
1773+
crate::logger::verbose(&format!("→ POST {url}"));
1774+
let form = form_factory();
1775+
self.apply_auth(self.client.post(&url))
1776+
.multipart(form)
1777+
.send()
1778+
.with_context(|| format!("Failed to POST {url}"))
1779+
})
1780+
}
1781+
17481782
pub fn patch_form(&self, path: &str, params: &[(String, String)]) -> Result<serde_json::Value> {
17491783
let url = format!("{}{}", self.base_url, path);
17501784
self.execute(|| {

src/api/space.rs

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
use anyhow::Result;
1+
use std::collections::BTreeMap;
2+
3+
use anyhow::{Context, Result};
24
use serde::{Deserialize, Serialize};
35

46
use super::BacklogClient;
@@ -17,6 +19,26 @@ pub struct Space {
1719
pub updated: String,
1820
}
1921

22+
#[derive(Debug, Clone, Serialize, Deserialize)]
23+
#[serde(rename_all = "camelCase")]
24+
pub struct SpaceAttachmentUser {
25+
pub id: u64,
26+
pub user_id: Option<String>,
27+
pub name: String,
28+
#[serde(flatten)]
29+
pub extra: BTreeMap<String, serde_json::Value>,
30+
}
31+
32+
#[derive(Debug, Clone, Serialize, Deserialize)]
33+
#[serde(rename_all = "camelCase")]
34+
pub struct SpaceAttachment {
35+
pub id: u64,
36+
pub name: String,
37+
pub size: u64,
38+
pub created_user: SpaceAttachmentUser,
39+
pub created: String,
40+
}
41+
2042
impl BacklogClient {
2143
pub fn get_space(&self) -> Result<Space> {
2244
let value = self.get("/space")?;
@@ -26,6 +48,26 @@ impl BacklogClient {
2648
pub fn download_space_image(&self) -> Result<(Vec<u8>, String)> {
2749
self.download("/space/image")
2850
}
51+
52+
pub fn upload_space_attachment(&self, file_path: &std::path::Path) -> Result<SpaceAttachment> {
53+
let filename = file_path
54+
.file_name()
55+
.and_then(|n| n.to_str())
56+
.unwrap_or("attachment")
57+
.to_string();
58+
let file_len = std::fs::metadata(file_path)
59+
.with_context(|| format!("Failed to access {}", file_path.display()))?
60+
.len();
61+
let file_path = file_path.to_path_buf();
62+
let value = self.post_multipart("/space/attachment", || {
63+
let file =
64+
std::fs::File::open(&file_path).expect("file open succeeded during pre-check");
65+
let part = reqwest::blocking::multipart::Part::reader_with_length(file, file_len)
66+
.file_name(filename.clone());
67+
reqwest::blocking::multipart::Form::new().part("file", part)
68+
})?;
69+
deserialize(value)
70+
}
2971
}
3072

3173
#[cfg(test)]

src/api/user.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,10 @@ impl BacklogClient {
146146
deserialize(value)
147147
}
148148

149+
pub fn download_user_icon(&self, user_id: u64) -> Result<(Vec<u8>, String)> {
150+
self.download(&format!("/users/{user_id}/icon"))
151+
}
152+
149153
pub fn add_star(&self, params: &[(String, String)]) -> Result<()> {
150154
self.post_form("/stars", params)?;
151155
Ok(())

src/cmd/space/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ mod licence;
55
mod notification;
66
mod show;
77
mod update_notification;
8+
mod upload_attachment;
89

910
pub use activities::{SpaceActivitiesArgs, activities};
1011
pub use image::{SpaceImageArgs, image};
@@ -21,3 +22,4 @@ pub use licence::{SpaceLicenceArgs, licence};
2122
pub use notification::{SpaceNotificationArgs, notification};
2223
pub use show::{SpaceShowArgs, show};
2324
pub use update_notification::{SpaceUpdateNotificationArgs, update_notification};
25+
pub use upload_attachment::{SpaceUploadAttachmentArgs, upload_attachment};

src/cmd/space/upload_attachment.rs

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
use anstream::println;
2+
use anyhow::Result;
3+
use std::path::PathBuf;
4+
5+
use crate::api::{BacklogApi, BacklogClient, space::SpaceAttachment};
6+
7+
pub struct SpaceUploadAttachmentArgs {
8+
file: PathBuf,
9+
json: bool,
10+
}
11+
12+
impl SpaceUploadAttachmentArgs {
13+
pub fn new(file: PathBuf, json: bool) -> Self {
14+
Self { file, json }
15+
}
16+
}
17+
18+
pub fn upload_attachment(args: &SpaceUploadAttachmentArgs) -> Result<()> {
19+
let client = BacklogClient::from_config()?;
20+
upload_attachment_with(args, &client)
21+
}
22+
23+
pub fn upload_attachment_with(
24+
args: &SpaceUploadAttachmentArgs,
25+
api: &dyn BacklogApi,
26+
) -> Result<()> {
27+
let attachment = api.upload_space_attachment(&args.file)?;
28+
if args.json {
29+
crate::cmd::print_json(&attachment)?;
30+
} else {
31+
println!("{}", format_attachment_text(&attachment));
32+
}
33+
Ok(())
34+
}
35+
36+
fn format_attachment_text(a: &SpaceAttachment) -> String {
37+
format!(
38+
"ID: {}\nName: {}\nSize: {} bytes\nCreated: {}",
39+
a.id, a.name, a.size, a.created
40+
)
41+
}
42+
43+
#[cfg(test)]
44+
mod tests {
45+
use super::*;
46+
use anyhow::anyhow;
47+
use std::collections::BTreeMap;
48+
use tempfile::NamedTempFile;
49+
50+
use crate::api::space::{SpaceAttachment, SpaceAttachmentUser};
51+
52+
struct MockApi {
53+
result: Option<SpaceAttachment>,
54+
}
55+
56+
impl crate::api::BacklogApi for MockApi {
57+
fn upload_space_attachment(
58+
&self,
59+
_file_path: &std::path::Path,
60+
) -> anyhow::Result<SpaceAttachment> {
61+
self.result.clone().ok_or_else(|| anyhow!("upload failed"))
62+
}
63+
}
64+
65+
fn sample_attachment() -> SpaceAttachment {
66+
SpaceAttachment {
67+
id: 1,
68+
name: "test.txt".to_string(),
69+
size: 100,
70+
created_user: SpaceAttachmentUser {
71+
id: 1,
72+
user_id: Some("alice".to_string()),
73+
name: "Alice".to_string(),
74+
extra: BTreeMap::new(),
75+
},
76+
created: "2024-01-01T00:00:00Z".to_string(),
77+
}
78+
}
79+
80+
fn args(json: bool) -> SpaceUploadAttachmentArgs {
81+
let tmp = NamedTempFile::new().unwrap();
82+
SpaceUploadAttachmentArgs::new(tmp.path().to_path_buf(), json)
83+
}
84+
85+
#[test]
86+
fn upload_attachment_with_text_output_succeeds() {
87+
let api = MockApi {
88+
result: Some(sample_attachment()),
89+
};
90+
assert!(upload_attachment_with(&args(false), &api).is_ok());
91+
}
92+
93+
#[test]
94+
fn upload_attachment_with_json_output_succeeds() {
95+
let api = MockApi {
96+
result: Some(sample_attachment()),
97+
};
98+
assert!(upload_attachment_with(&args(true), &api).is_ok());
99+
}
100+
101+
#[test]
102+
fn upload_attachment_with_propagates_api_error() {
103+
let api = MockApi { result: None };
104+
let err = upload_attachment_with(&args(false), &api).unwrap_err();
105+
assert!(err.to_string().contains("upload failed"));
106+
}
107+
108+
#[test]
109+
fn format_attachment_text_contains_all_fields() {
110+
let text = format_attachment_text(&sample_attachment());
111+
assert!(text.contains("1"));
112+
assert!(text.contains("test.txt"));
113+
assert!(text.contains("100"));
114+
assert!(text.contains("2024-01-01T00:00:00Z"));
115+
}
116+
}

0 commit comments

Comments
 (0)