diff --git a/crates/cli/src/base/create.rs b/crates/cli/src/base/create.rs index b69523a..afd66ca 100644 --- a/crates/cli/src/base/create.rs +++ b/crates/cli/src/base/create.rs @@ -1,4 +1,111 @@ -pub fn handle(data: &str) -> anyhow::Result<()> { - println!("Creating a paste with data: {data}"); +use clap::Args; +use std::fs; +use std::io::{self, BufReader, IsTerminal}; +use std::path::PathBuf; +use textbin_client::Client; + +#[derive(Args)] +pub struct CreateArgs { + /// Accepts static strings and stdin inputs including pipes. + /// When given a string with a '@' prefix, it'll treat it as a file and will attempt + /// to read the data from the file, essentially the same as `cat | textbin create` + data: Option, + + /// If provided, `syntax` will be used as reference to syntax highlight the data. e.g. go, rust, json + #[arg(long, visible_alias = "ext")] + syntax: Option, +} + +pub fn handle(args: &CreateArgs) -> anyhow::Result<()> { + let client = Client::from_env(); + let syntax = args.syntax.as_deref(); + + let paste = match &args.data { + Some(data) => match create_data_from_arg(data)? { + Data::File(path) => { + // stream from the file to API + let file = fs::File::open(path)?; + let reader = BufReader::new(file); + + client.create_paste_stream(reader, syntax)? + } + Data::Literal(data) => client.create_paste(data, syntax)?, + }, + None => { + // If stdin is still the interactive terminal, reading from it would + // look like the command hung while waiting for EOF. When stdin is + // not a terminal, it may be a pipe, redirected file, heredoc, or + // another non-interactive source; all of those can be streamed. + if io::stdin().is_terminal() { + anyhow::bail!("provide paste data as an argument or pipe it on stdin"); + } + + client.create_paste_stream(io::stdin(), syntax)? + } + }; + + println!("{}", paste.id); Ok(()) } + +#[derive(Debug, PartialEq, Eq)] +enum Data { + File(PathBuf), + Literal(String), +} + +fn create_data_from_arg(data: &str) -> anyhow::Result { + match data.strip_prefix('@') { + Some(path) => match fs::exists(path) { + Ok(true) => Ok(Data::File(PathBuf::from(path))), + Ok(false) => anyhow::bail!("file does not exist at path {}", path), + Err(err) => anyhow::bail!("error checking path: {}", err), + }, + None => Ok(Data::Literal(data.to_string())), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + #[test] + fn create_data_from_arg_treats_plain_data_as_literal() { + assert_eq!( + create_data_from_arg("hello world").unwrap(), + Data::Literal("hello world".to_string()) + ); + } + + #[test] + fn create_data_from_arg_treats_existing_at_path_as_file() { + let path = temp_file_path("create-data-file"); + fs::write(&path, "hello from file").unwrap(); + + let result = create_data_from_arg(&format!("@{}", path.display())); + + fs::remove_file(&path).unwrap(); + assert_eq!(result.unwrap(), Data::File(path)); + } + + #[test] + fn create_data_from_arg_errors_when_at_path_does_not_exist() { + let path = temp_file_path("missing-create-data-file"); + let result = create_data_from_arg(&format!("@{}", path.display())); + + assert_eq!( + result.unwrap_err().to_string(), + format!("file does not exist at path {}", path.display()) + ); + } + + fn temp_file_path(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + + std::env::temp_dir().join(format!("textbin-{label}-{}-{nonce}", std::process::id())) + } +} diff --git a/crates/cli/src/base/mod.rs b/crates/cli/src/base/mod.rs index b0fe858..5dadf44 100644 --- a/crates/cli/src/base/mod.rs +++ b/crates/cli/src/base/mod.rs @@ -3,22 +3,19 @@ mod show; use clap::Subcommand; -use crate::base::show::ShowArgs; +use crate::base::{create::CreateArgs, show::ShowArgs}; #[derive(Subcommand)] pub enum Commands { /// Retrieve and print a paste Show(ShowArgs), /// Create a new paste - Create { - /// data to paste. - data: String, - }, + Create(CreateArgs), } pub fn handle(command: &Commands) -> anyhow::Result<()> { match command { Commands::Show(args) => show::handle(args), - Commands::Create { data } => create::handle(data), + Commands::Create(args) => create::handle(args), } } diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index ce5e4f0..6fe25de 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -1,14 +1,18 @@ use reqwest::StatusCode; +use reqwest::blocking::Body; +use reqwest::header::CONTENT_TYPE; use serde::Deserialize; use std::collections::BTreeMap; use std::error; use std::fmt; +use std::io::Read; const DEFAULT_TEXTBIN_URL: &str = "http://localhost:4000"; #[derive(Debug, Clone)] pub struct Client { base_url: String, + http: reqwest::blocking::Client, } impl Client { @@ -22,31 +26,80 @@ impl Client { pub fn new(base_url: impl Into) -> Self { Self { base_url: base_url.into().trim_end_matches('/').to_string(), + http: reqwest::blocking::Client::new(), } } pub fn get_paste(&self, id: &str) -> Result { let url = format!("{}/api/v1/pastes/{id}", self.base_url); - let response = reqwest::blocking::get(&url).map_err(|source| Error::Request { - url: url.clone(), - source, - })?; - - let status = response.status(); - let body = response.text().map_err(|source| Error::ReadResponse { - url: url.clone(), - source, - })?; - - if !status.is_success() { - return Err(Error::Api(format_api_error(status, &body))); - } + let response = self + .http + .get(&url) + .send() + .map_err(|source| Error::Request { + url: url.clone(), + source, + })?; - let response = serde_json::from_str::(&body) - .map_err(|source| Error::Decode { source })?; + let response = decode_response::(&url, response)?; Ok(response.data) } + + pub fn create_paste( + &self, + data: String, + syntax_highlight: Option<&str>, + ) -> Result { + self.create_paste_body(Body::from(data), syntax_highlight) + } + + pub fn create_paste_stream( + &self, + reader: R, + syntax_highlight: Option<&str>, + ) -> Result + where + R: Read + Send + 'static, + { + self.create_paste_body(Body::new(reader), syntax_highlight) + } + + fn create_paste_body( + &self, + body: Body, + syntax_highlight: Option<&str>, + ) -> Result { + let url = self.create_paste_url(syntax_highlight); + let response = self + .http + .post(&url) + .header(CONTENT_TYPE, "text/plain") + .body(body) + .send() + .map_err(|source| Error::Request { + url: url.clone(), + source, + })?; + + let response = decode_response::(&url, response)?; + + Ok(response.data) + } + + fn create_paste_url(&self, syntax_highlight: Option<&str>) -> String { + let url = format!("{}/api/v1/pastes", self.base_url); + + match syntax_highlight.filter(|syntax| !syntax.is_empty()) { + Some(syntax_highlight) => { + let mut url = reqwest::Url::parse(&url).expect("client base_url must be valid URL"); + url.query_pairs_mut() + .append_pair("syntax_highlight", syntax_highlight); + url.into() + } + None => url, + } + } } #[derive(Debug, Deserialize)] @@ -54,12 +107,23 @@ struct ShowResponse { data: Paste, } +#[derive(Debug, Deserialize)] +struct CreateResponse { + data: CreatedPaste, +} + #[derive(Debug, Clone, Deserialize)] pub struct Paste { pub data: String, pub syntax_highlight: String, } +#[derive(Debug, Clone, Deserialize)] +pub struct CreatedPaste { + pub id: String, + pub syntax_highlight: String, +} + #[derive(Debug, Deserialize)] struct ApiErrorResponse { errors: ApiErrors, @@ -103,6 +167,30 @@ impl error::Error for Error { } } +fn decode_response(url: &str, response: reqwest::blocking::Response) -> Result +where + T: for<'de> Deserialize<'de>, +{ + let status = response.status(); + let body = response.text().map_err(|source| Error::ReadResponse { + url: url.to_string(), + source, + })?; + + if !status.is_success() { + return Err(Error::Api(format_api_error(status, &body))); + } + + decode_json(&body) +} + +fn decode_json(body: &str) -> Result +where + T: for<'de> Deserialize<'de>, +{ + serde_json::from_str(body).map_err(|source| Error::Decode { source }) +} + fn format_api_error(status: StatusCode, body: &str) -> String { let detail = serde_json::from_str::(body) .ok() @@ -141,6 +229,30 @@ mod tests { assert_eq!(client.base_url, "http://localhost:4000"); } + #[test] + fn create_paste_url_omits_empty_syntax_highlight() { + let client = Client::new("http://localhost:4000/"); + + assert_eq!( + client.create_paste_url(None), + "http://localhost:4000/api/v1/pastes" + ); + assert_eq!( + client.create_paste_url(Some("")), + "http://localhost:4000/api/v1/pastes" + ); + } + + #[test] + fn create_paste_url_adds_syntax_highlight_query_param() { + let client = Client::new("http://localhost:4000/"); + + assert_eq!( + client.create_paste_url(Some("rust")), + "http://localhost:4000/api/v1/pastes?syntax_highlight=rust" + ); + } + #[test] fn format_api_error_uses_detail_from_json_response() { let message = format_api_error( @@ -170,4 +282,15 @@ mod tests { assert_eq!(message, "paste request failed: 500 Internal Server Error"); } + + #[test] + fn decodes_create_response_metadata() { + let response = decode_json::( + r#"{"data":{"id":"00000000-0000-0000-0000-000000000000","syntax_highlight":"plain"}}"#, + ) + .unwrap(); + + assert_eq!(response.data.id, "00000000-0000-0000-0000-000000000000"); + assert_eq!(response.data.syntax_highlight, "plain"); + } }