diff --git a/README.md b/README.md index 7abe42f..9a03ccf 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,7 @@ As part of building the import server, the following commands exercise each oper - [x] trello stack get - includes list of cards - [x] trello card get - includes list of attachments - [x] trello attachment get - includes download url +- [x] trello attachment save - saves to disk - [ ] nextcloud deck get (was board list) - [ ] nextcloud board get (was stack list) - [ ] nextcloud stack get (was card list) diff --git a/src/lib.rs b/src/lib.rs index b49aac8..8aec10c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -67,10 +67,10 @@ pub struct Ctx { pub net: Net, pub prt: Printer, } -impl Default for Ctx { - fn default() -> Self { +impl Ctx { + pub fn new(base: impl Into) -> Self { Self { - fs: kxio::fs::new(PathBuf::default()), + fs: kxio::fs::new(base), net: kxio::net::new(), prt: kxio::print::standard(), } @@ -79,7 +79,7 @@ impl Default for Ctx { #[derive(Clone)] pub(crate) struct FullCtx { - // pub fs: FileSystem, + pub fs: FileSystem, pub net: Net, pub prt: Printer, pub cfg: AppConfig, @@ -121,7 +121,7 @@ pub async fn run(ctx: Ctx) -> color_eyre::Result<()> { commands .command .execute(FullCtx { - // fs: ctx.fs, + fs: ctx.fs, net: ctx.net, prt: ctx.prt, cfg, diff --git a/src/main.rs b/src/main.rs index d253395..7b8210e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,5 +6,5 @@ use trello_to_deck::{run, Ctx}; #[tokio::main] #[cfg_attr(test, mutants::skip)] async fn main() -> Result<()> { - run(Ctx::default()).await + run(Ctx::new(std::env::current_dir()?)).await } diff --git a/src/nextcloud/tests.rs b/src/nextcloud/tests.rs index 883a6bf..893e4f8 100644 --- a/src/nextcloud/tests.rs +++ b/src/nextcloud/tests.rs @@ -118,8 +118,8 @@ mod commands { .body(include_str!("../tests/responses/nextcloud-board-list.json")) .expect("mock request"); - // let fs = given::a_filesystem(); - let ctx = given::a_full_context(mock_net); + let fs = given::a_filesystem(); + let ctx = given::a_full_context(mock_net, fs); let prt = ctx.prt.clone(); let prt = prt.as_test().unwrap(); @@ -151,8 +151,8 @@ mod commands { .body(include_str!("../tests/responses/nextcloud-board-list.json")) .expect("mock request"); - // let fs = given::a_filesystem(); - let ctx = given::a_full_context(mock_net); + let fs = given::a_filesystem(); + let ctx = given::a_full_context(mock_net, fs); let prt = ctx.prt.clone(); let prt = prt.as_test().unwrap(); @@ -195,8 +195,8 @@ mod commands { )) .expect("mock request"); - // let fs = given::a_filesystem(); - let ctx = given::a_full_context(mock_net); + let fs = given::a_filesystem(); + let ctx = given::a_full_context(mock_net, fs); //when let result = ctx @@ -220,8 +220,8 @@ mod commands { .body(include_str!("../tests/responses/nextcloud-stack-list.json")) .expect("mock request"); - // let fs = given::a_filesystem(); - let ctx = given::a_full_context(mock_net); + let fs = given::a_filesystem(); + let ctx = given::a_full_context(mock_net, fs); //when let result = ctx @@ -332,8 +332,8 @@ mod commands { .body(include_str!("../tests/responses/nextcloud-card-list.json")) .expect("mock request"); - // let fs = given::a_filesystem(); - let ctx = given::a_full_context(mock_net); + let fs = given::a_filesystem(); + let ctx = given::a_full_context(mock_net, fs); //when let result = ctx @@ -378,8 +378,8 @@ mod commands { .body(include_str!("../tests/responses/nextcloud-card-get.json")) .expect("mock request"); - // let fs = given::a_filesystem(); - let ctx = given::a_full_context(mock_net); + let fs = given::a_filesystem(); + let ctx = given::a_full_context(mock_net, fs); //when let result = ctx @@ -432,8 +432,8 @@ mod commands { )) .expect("mock request"); - // let fs = given::a_filesystem(); - let ctx = given::a_full_context(mock_net); + let fs = given::a_filesystem(); + let ctx = given::a_full_context(mock_net, fs); //when let result = ctx @@ -484,8 +484,8 @@ mod commands { .mock() .expect("mock request"); - // let fs = given::a_filesystem(); - let ctx = given::a_full_context(mock_net); + let fs = given::a_filesystem(); + let ctx = given::a_full_context(mock_net, fs); //when let result = ctx diff --git a/src/tests/mod.rs b/src/tests/mod.rs index e12f7e6..c93f685 100644 --- a/src/tests/mod.rs +++ b/src/tests/mod.rs @@ -131,12 +131,9 @@ pub(crate) mod given { Ctx { fs, net, prt } } - pub(crate) fn a_full_context( - mock_net: MockNet, - // fs: TempFileSystem - ) -> FullCtx { + pub(crate) fn a_full_context(mock_net: MockNet, fs: TempFileSystem) -> FullCtx { FullCtx { - // fs: fs.as_real(), + fs: fs.as_real(), net: mock_net.into(), prt: a_printer(), cfg: AppConfig { diff --git a/src/tests/responses/trello-attachment-save.png b/src/tests/responses/trello-attachment-save.png new file mode 100644 index 0000000..2f10201 Binary files /dev/null and b/src/tests/responses/trello-attachment-save.png differ diff --git a/src/trello/attachment.rs b/src/trello/attachment.rs index cbd661b..4ec8644 100644 --- a/src/trello/attachment.rs +++ b/src/trello/attachment.rs @@ -1,3 +1,5 @@ +use std::path::PathBuf; + // use clap::Parser; use color_eyre::Result; @@ -15,6 +17,11 @@ pub(crate) enum TrelloAttachmentCommand { card_id: String, attachment_id: String, }, + Save { + card_id: String, + attachment_id: String, + file_name: Option, // will use file name from attachment if not provided. + }, } impl Execute for TrelloAttachmentCommand { @@ -25,12 +32,11 @@ impl Execute for TrelloAttachmentCommand { card_id, attachment_id, } => { + let trello_card_id = TrelloCardId::new(card_id); + let trello_attachment_id = TrelloAttachmentId::new(attachment_id); let api_result = ctx .trello_client() - .card_attachment( - &TrelloCardId::new(card_id), - &TrelloAttachmentId::new(attachment_id), - ) + .card_attachment(&trello_card_id, &trello_attachment_id) .await; if dump { p!(ctx.prt, "{}", api_result.text); @@ -40,6 +46,20 @@ impl Execute for TrelloAttachmentCommand { } Ok(()) } + Self::Save { + card_id, + attachment_id, + file_name, + } => { + let trello_card_id = TrelloCardId::new(card_id); + let trello_attachment_id = TrelloAttachmentId::new(attachment_id); + let file_name = ctx + .trello_client() + .save_attachment(&trello_card_id, &trello_attachment_id, file_name) + .await?; + p!(ctx.prt, "Wrote: {}", file_name.display()); + Ok(()) + } } } } diff --git a/src/trello/client.rs b/src/trello/client.rs index d6f5573..f0d2739 100644 --- a/src/trello/client.rs +++ b/src/trello/client.rs @@ -1,6 +1,8 @@ // use std::collections::HashMap; +use std::path::PathBuf; +use color_eyre::eyre::Context; use kxio::net::{Net, ReqBuilder}; use crate::trello::model::{TrelloAttachment, TrelloAttachmentId}; @@ -26,6 +28,14 @@ impl<'ctx> TrelloClient<'ctx> { f!("https://api.trello.com/1{path}") } + fn auth_headers(&self) -> HashMap { + let api_key = &self.ctx.cfg.trello.api_key; + let api_secret = &self.ctx.cfg.trello.api_secret; + HashMap::from([( + s!("Authorization"), + f!(r#"OAuth oauth_consumer_key="{api_key}", oauth_token="{api_secret}""#,), + )]) + } fn common_headers(&self) -> HashMap { let api_key = &self.ctx.cfg.trello.api_key; let api_secret = &self.ctx.cfg.trello.api_secret; @@ -53,6 +63,34 @@ impl<'ctx> TrelloClient<'ctx> { ) .await } + + pub(crate) async fn save_attachment( + &self, + card_id: &TrelloCardId, + attachment_id: &TrelloAttachmentId, + file_name: Option, + ) -> color_eyre::Result { + let attachment = self.card_attachment(card_id, attachment_id).await.result?; + let url = attachment.url; + let file_name = file_name.unwrap_or_else(|| attachment.file_name.into()); + crate::e!(self.ctx.prt, "file_name: {}", file_name.display()); + crate::e!(self.ctx.prt, "base: {}", self.ctx.fs.base().display()); + let file_name = self.ctx.fs.base().join(file_name); + crate::e!(self.ctx.prt, "file_name: {}", file_name.display()); + let resp = self + .ctx + .net + .get(url) + .headers(self.auth_headers()) + .header("accept", "application/octet") + .send() + .await? + .bytes() + .await?; + let file = self.ctx.fs.file(&file_name); + file.write(resp).context("writing to disk")?; + Ok(file_name) + } } impl<'ctx> TrelloClient<'ctx> { diff --git a/src/trello/tests.rs b/src/trello/tests.rs index 9c95ce0..f80bf49 100644 --- a/src/trello/tests.rs +++ b/src/trello/tests.rs @@ -75,8 +75,8 @@ mod commands { .body(include_str!("../tests/responses/trello-board-get.json")) .expect("mock request"); - // let fs = given::a_filesystem(); - let ctx = given::a_full_context(mock_net); + let fs = given::a_filesystem(); + let ctx = given::a_full_context(mock_net, fs); //when let result = ctx @@ -123,8 +123,8 @@ mod commands { prep_mock_get(&mock_net); - // let fs = given::a_filesystem(); - let ctx = given::a_full_context(mock_net); + let fs = given::a_filesystem(); + let ctx = given::a_full_context(mock_net, fs); let prt = ctx.prt.clone(); let prt = prt.as_test().unwrap(); let command = Command::Trello(TrelloCommand::Card(TrelloCardCommand::Get { @@ -147,8 +147,8 @@ mod commands { prep_mock_get(&mock_net); - // let fs = given::a_filesystem(); - let ctx = given::a_full_context(mock_net); + let fs = given::a_filesystem(); + let ctx = given::a_full_context(mock_net, fs); let prt = ctx.prt.clone(); let prt = prt.as_test().unwrap(); let command = Command::Trello(TrelloCommand::Card(TrelloCardCommand::Get { @@ -195,8 +195,8 @@ mod commands { prep_mock_get(&mock_net); - // let fs = given::a_filesystem(); - let ctx = given::a_full_context(mock_net); + let fs = given::a_filesystem(); + let ctx = given::a_full_context(mock_net, fs); let prt = ctx.prt.clone(); let prt = prt.as_test().unwrap(); let command = @@ -207,7 +207,7 @@ mod commands { })); //when - command.execute(ctx).await; + command.execute(ctx).await.expect("execute"); //then let output = prt.output(); @@ -221,8 +221,8 @@ mod commands { prep_mock_get(&mock_net); - // let fs = given::a_filesystem(); - let ctx = given::a_full_context(mock_net); + let fs = given::a_filesystem(); + let ctx = given::a_full_context(mock_net, fs); let prt = ctx.prt.clone(); let prt = prt.as_test().unwrap(); let command = @@ -233,7 +233,7 @@ mod commands { })); //when - command.execute(ctx).await; + command.execute(ctx).await.expect("execute"); //then let output = prt.output(); @@ -255,6 +255,85 @@ mod commands { .expect("mock request"); } } + + mod save { + use super::*; + use bytes::Bytes; + + #[tokio::test] + async fn save_writes_file() { + //given + let mock_net = kxio::net::mock(); + + prep_mock_get(&mock_net); + + let fs = given::a_filesystem(); + let test_fs = fs.clone(); + let ctx = given::a_full_context(mock_net, fs); + let command = + Command::Trello(TrelloCommand::Attachment(TrelloAttachmentCommand::Save { + card_id: s!("65ad94865aed24f70ecdcebb"), + attachment_id: s!("65ad94875aed24f70ecdd037"), + file_name: None, + })); + + //when + command.execute(ctx).await.expect("execute"); + + //then + let file = test_fs.base().join("Backlog.png"); + assert!(file.exists()); + } + + #[tokio::test] + async fn save_log_output() { + //given + let mock_net = kxio::net::mock(); + + prep_mock_get(&mock_net); + + let fs = given::a_filesystem(); + let test_fs = fs.clone(); + let ctx = given::a_full_context(mock_net, fs); + let prt = ctx.prt.clone(); + let prt = prt.as_test().unwrap(); + let command = + Command::Trello(TrelloCommand::Attachment(TrelloAttachmentCommand::Save { + card_id: s!("65ad94865aed24f70ecdcebb"), + attachment_id: s!("65ad94875aed24f70ecdd037"), + file_name: None, + })); + + //when + command.execute(ctx).await.expect("execute"); + + //then + let file = test_fs.base().join("Backlog.png"); + drop(test_fs); + let output = prt.output(); + assert_eq!(output.trim(), crate::f!("Wrote: {}", file.display())); + } + + fn prep_mock_get(mock_net: &MockNet) { + mock_net + .on() + .get("https://api.trello.com/1/cards/65ad94865aed24f70ecdcebb/attachments/65ad94875aed24f70ecdd037") + .header("authorization", "OAuth oauth_consumer_key=\"trello-api-key\", oauth_token=\"trello-api-secret\"") + .header("accept", "application/json") + .header("content-type", "application/json") + .respond(StatusCode::OK) + .body(include_str!("../tests/responses/trello-attachment-get.json")) + .expect("mock request 1"); + + mock_net + .on() + .get("https://trello.com/1/cards/65ad94865aed24f70ecdcebb/attachments/65ad94875aed24f70ecdd037/download/Backlog.png") + .header("authorization", "OAuth oauth_consumer_key=\"trello-api-key\", oauth_token=\"trello-api-secret\"") + .respond(StatusCode::OK) + .body(Bytes::from(include_bytes!("../tests/responses/trello-attachment-save.png").as_slice())) + .expect("mock request 2"); + } + } } }