Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ path = "src/main.rs"
anyhow = "1"
clap = { version = "4", features = ["derive"] }
dirs = "6"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "multipart", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "1.0"
Expand Down
36 changes: 35 additions & 1 deletion src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ use pull_request::{
use rate_limit::RateLimit;
use resolution::Resolution;
use shared_file::SharedFile;
use space::Space;
use space::{Space, SpaceAttachment};
use space_notification::SpaceNotification;
use team::Team;
use user::{RecentlyViewedIssue, RecentlyViewedProject, RecentlyViewedWiki, Star, StarCount, User};
Expand Down Expand Up @@ -91,6 +91,9 @@ pub trait BacklogApi {
fn download_space_image(&self) -> Result<(Vec<u8>, String)> {
unimplemented!()
}
fn upload_space_attachment(&self, _file_path: &std::path::Path) -> Result<SpaceAttachment> {
unimplemented!()
}
fn get_rate_limit(&self) -> Result<RateLimit> {
unimplemented!()
}
Expand Down Expand Up @@ -595,6 +598,9 @@ pub trait BacklogApi {
fn count_user_stars(&self, _user_id: u64, _params: &[(String, String)]) -> Result<StarCount> {
unimplemented!()
}
fn download_user_icon(&self, _user_id: u64) -> Result<(Vec<u8>, String)> {
unimplemented!()
}
fn add_star(&self, _params: &[(String, String)]) -> Result<()> {
unimplemented!()
}
Expand Down Expand Up @@ -774,6 +780,10 @@ impl BacklogApi for BacklogClient {
self.download_space_image()
}

fn upload_space_attachment(&self, file_path: &std::path::Path) -> Result<SpaceAttachment> {
self.upload_space_attachment(file_path)
}

fn get_rate_limit(&self) -> Result<RateLimit> {
self.get_rate_limit()
}
Expand Down Expand Up @@ -1390,6 +1400,10 @@ impl BacklogApi for BacklogClient {
self.count_user_stars(user_id, params)
}

fn download_user_icon(&self, user_id: u64) -> Result<(Vec<u8>, String)> {
self.download_user_icon(user_id)
}

fn add_star(&self, params: &[(String, String)]) -> Result<()> {
self.add_star(params)
}
Expand Down Expand Up @@ -1745,6 +1759,26 @@ impl BacklogClient {
})
}

/// Post a multipart/form-data request.
///
/// The provided factory closure is called on each attempt to build a fresh
/// `reqwest::blocking::multipart::Form`, allowing this method to reuse the
/// standard [`Self::execute`] retry logic (including retry-on-401).
pub fn post_multipart<F>(&self, path: &str, form_factory: F) -> Result<serde_json::Value>
where
F: Fn() -> reqwest::blocking::multipart::Form,
{
let url = format!("{}{}", self.base_url, path);
self.execute(|| {
crate::logger::verbose(&format!("→ POST {url}"));
let form = form_factory();
self.apply_auth(self.client.post(&url))
.multipart(form)
.send()
.with_context(|| format!("Failed to POST {url}"))
})
}

pub fn patch_form(&self, path: &str, params: &[(String, String)]) -> Result<serde_json::Value> {
let url = format!("{}{}", self.base_url, path);
self.execute(|| {
Expand Down
44 changes: 43 additions & 1 deletion src/api/space.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
use anyhow::Result;
use std::collections::BTreeMap;

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};

use super::BacklogClient;
Expand All @@ -17,6 +19,26 @@ pub struct Space {
pub updated: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SpaceAttachmentUser {
pub id: u64,
pub user_id: Option<String>,
pub name: String,
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SpaceAttachment {
pub id: u64,
pub name: String,
pub size: u64,
pub created_user: SpaceAttachmentUser,
pub created: String,
}

impl BacklogClient {
pub fn get_space(&self) -> Result<Space> {
let value = self.get("/space")?;
Expand All @@ -26,6 +48,26 @@ impl BacklogClient {
pub fn download_space_image(&self) -> Result<(Vec<u8>, String)> {
self.download("/space/image")
}

pub fn upload_space_attachment(&self, file_path: &std::path::Path) -> Result<SpaceAttachment> {
let filename = file_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("attachment")
.to_string();
let file_len = std::fs::metadata(file_path)
.with_context(|| format!("Failed to access {}", file_path.display()))?
.len();
let file_path = file_path.to_path_buf();
let value = self.post_multipart("/space/attachment", || {
let file =
std::fs::File::open(&file_path).expect("file open succeeded during pre-check");
let part = reqwest::blocking::multipart::Part::reader_with_length(file, file_len)
.file_name(filename.clone());
reqwest::blocking::multipart::Form::new().part("file", part)
})?;
deserialize(value)
}
}

#[cfg(test)]
Expand Down
4 changes: 4 additions & 0 deletions src/api/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,10 @@ impl BacklogClient {
deserialize(value)
}

pub fn download_user_icon(&self, user_id: u64) -> Result<(Vec<u8>, String)> {
self.download(&format!("/users/{user_id}/icon"))
}

pub fn add_star(&self, params: &[(String, String)]) -> Result<()> {
self.post_form("/stars", params)?;
Ok(())
Expand Down
2 changes: 2 additions & 0 deletions src/cmd/space/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ mod licence;
mod notification;
mod show;
mod update_notification;
mod upload_attachment;

pub use activities::{SpaceActivitiesArgs, activities};
pub use image::{SpaceImageArgs, image};
Expand All @@ -21,3 +22,4 @@ pub use licence::{SpaceLicenceArgs, licence};
pub use notification::{SpaceNotificationArgs, notification};
pub use show::{SpaceShowArgs, show};
pub use update_notification::{SpaceUpdateNotificationArgs, update_notification};
pub use upload_attachment::{SpaceUploadAttachmentArgs, upload_attachment};
116 changes: 116 additions & 0 deletions src/cmd/space/upload_attachment.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
use anstream::println;
use anyhow::Result;
use std::path::PathBuf;

use crate::api::{BacklogApi, BacklogClient, space::SpaceAttachment};

pub struct SpaceUploadAttachmentArgs {
file: PathBuf,
json: bool,
}

impl SpaceUploadAttachmentArgs {
pub fn new(file: PathBuf, json: bool) -> Self {
Self { file, json }
}
}

pub fn upload_attachment(args: &SpaceUploadAttachmentArgs) -> Result<()> {
let client = BacklogClient::from_config()?;
upload_attachment_with(args, &client)
}

pub fn upload_attachment_with(
args: &SpaceUploadAttachmentArgs,
api: &dyn BacklogApi,
) -> Result<()> {
let attachment = api.upload_space_attachment(&args.file)?;
if args.json {
crate::cmd::print_json(&attachment)?;
} else {
println!("{}", format_attachment_text(&attachment));
}
Ok(())
}

fn format_attachment_text(a: &SpaceAttachment) -> String {
format!(
"ID: {}\nName: {}\nSize: {} bytes\nCreated: {}",
a.id, a.name, a.size, a.created
)
}

#[cfg(test)]
mod tests {
use super::*;
use anyhow::anyhow;
use std::collections::BTreeMap;
use tempfile::NamedTempFile;

use crate::api::space::{SpaceAttachment, SpaceAttachmentUser};

struct MockApi {
result: Option<SpaceAttachment>,
}

impl crate::api::BacklogApi for MockApi {
fn upload_space_attachment(
&self,
_file_path: &std::path::Path,
) -> anyhow::Result<SpaceAttachment> {
self.result.clone().ok_or_else(|| anyhow!("upload failed"))
}
}

fn sample_attachment() -> SpaceAttachment {
SpaceAttachment {
id: 1,
name: "test.txt".to_string(),
size: 100,
created_user: SpaceAttachmentUser {
id: 1,
user_id: Some("alice".to_string()),
name: "Alice".to_string(),
extra: BTreeMap::new(),
},
created: "2024-01-01T00:00:00Z".to_string(),
}
}

fn args(json: bool) -> SpaceUploadAttachmentArgs {
let tmp = NamedTempFile::new().unwrap();
SpaceUploadAttachmentArgs::new(tmp.path().to_path_buf(), json)
}

#[test]
fn upload_attachment_with_text_output_succeeds() {
let api = MockApi {
result: Some(sample_attachment()),
};
assert!(upload_attachment_with(&args(false), &api).is_ok());
}

#[test]
fn upload_attachment_with_json_output_succeeds() {
let api = MockApi {
result: Some(sample_attachment()),
};
assert!(upload_attachment_with(&args(true), &api).is_ok());
}

#[test]
fn upload_attachment_with_propagates_api_error() {
let api = MockApi { result: None };
let err = upload_attachment_with(&args(false), &api).unwrap_err();
assert!(err.to_string().contains("upload failed"));
}

#[test]
fn format_attachment_text_contains_all_fields() {
let text = format_attachment_text(&sample_attachment());
assert!(text.contains("1"));
assert!(text.contains("test.txt"));
assert!(text.contains("100"));
assert!(text.contains("2024-01-01T00:00:00Z"));
}
}
Loading