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
111 changes: 109 additions & 2 deletions crates/cli/src/base/create.rs
Original file line number Diff line number Diff line change
@@ -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 <file> | textbin create`
data: Option<String>,

/// 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<String>,
}

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<Data> {
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()))
}
}
9 changes: 3 additions & 6 deletions crates/cli/src/base/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
}
155 changes: 139 additions & 16 deletions crates/client/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -22,44 +26,104 @@ impl Client {
pub fn new(base_url: impl Into<String>) -> 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<Paste, Error> {
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::<ShowResponse>(&body)
.map_err(|source| Error::Decode { source })?;
let response = decode_response::<ShowResponse>(&url, response)?;

Ok(response.data)
}

pub fn create_paste(
&self,
data: String,
syntax_highlight: Option<&str>,
) -> Result<CreatedPaste, Error> {
self.create_paste_body(Body::from(data), syntax_highlight)
}

pub fn create_paste_stream<R>(
&self,
reader: R,
syntax_highlight: Option<&str>,
) -> Result<CreatedPaste, Error>
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<CreatedPaste, Error> {
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::<CreateResponse>(&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)]
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,
Expand Down Expand Up @@ -103,6 +167,30 @@ impl error::Error for Error {
}
}

fn decode_response<T>(url: &str, response: reqwest::blocking::Response) -> Result<T, Error>
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<T>(body: &str) -> Result<T, Error>
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::<ApiErrorResponse>(body)
.ok()
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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::<CreateResponse>(
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");
}
}
Loading