diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 2f2db8f..ecef3eb 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -26,6 +26,7 @@ jm_client_core = { path = "../jm_client_core" } log = "0.4.11" opener = "0.4.1" reqwest = { version = "0.10", features = ["stream"] } +serde_json = "1.0.60" structopt = "0.3.21" term-table = "1.3.0" term_size = "0.3.2" diff --git a/cli/src/commands/cats.rs b/cli/src/commands/cats.rs index 506ed4e..3379759 100644 --- a/cli/src/commands/cats.rs +++ b/cli/src/commands/cats.rs @@ -1,16 +1,15 @@ -use crate::table::{self, IntoTableRow}; -use jm_client_core::util::api; -use reqwest::Client; +use crate::table::{self, AsTableRow}; +use jm_client_core::JMClient; -pub async fn run(http: &Client) -> anyhow::Result<()> { +pub async fn run(client: &JMClient) -> anyhow::Result<()> { // clone required, because for sorting the immutable reference will not work - let mut cats = api::cats(http).await?.clone(); + let mut cats = client.get_cats().await?.as_ref().clone(); cats.sort_by(|a, b| a.id.cmp(&b.id)); let mut table = table::list_table(); for cat in &cats { - table.add_row(cat.into_table_row()); + table.add_row(cat.as_table_row()); } println!("{}", table.render()); diff --git a/cli/src/commands/list.rs b/cli/src/commands/list.rs index 93dc836..f38b6a2 100644 --- a/cli/src/commands/list.rs +++ b/cli/src/commands/list.rs @@ -1,15 +1,17 @@ use anyhow::{Context, Result}; -use reqwest::Client; use std::{ io::Write, process::{Command, Stdio}, }; -use crate::table::{self, IntoTableRow}; -use jm_client_core::util::{self, api, MemeSorting}; +use crate::{ + table::{self, AsTableRow}, + util, +}; +use jm_client_core::{util::MemeSorting, JMClient}; pub async fn run( - http: &Client, + client: &JMClient, cat: Option, user: Option, sorting: Option, @@ -18,28 +20,28 @@ pub async fn run( // This needs to be done so both users, memes and categories will be requested // at once let (memes, ..) = tokio::try_join!( - api::memes( - http, - cat.as_ref().map(String::from), - user.as_ref().map(String::from) - ), + async { client.get_memes().await.map_err(|e| e.into()) }, async { if let Some(c) = cat.as_ref() { - util::assert_category_exists(http, c).await + util::assert_category_exists(client, c).await } else { Ok(()) } }, async { if let Some(u) = user.as_ref() { - util::assert_user_exists(http, u).await + util::assert_user_exists(client, u).await } else { Ok(()) } }, )?; - let mut memes = memes.iter().collect::>(); + let mut memes = memes + .iter() + .filter(|m| cat.as_ref().map(|c| &m.category == c).unwrap_or(true)) + .filter(|m| user.as_ref().map(|u| &m.user == u).unwrap_or(true)) + .collect::>(); if let Some(s) = sorting { s.sort_with(&mut memes); @@ -48,7 +50,7 @@ pub async fn run( let mut table = table::list_table(); for m in memes.iter() { - table.add_row(m.into_table_row()); + table.add_row(m.as_table_row()); } let table_str = table.render(); diff --git a/cli/src/commands/search.rs b/cli/src/commands/search.rs index 7eb7301..6717bcc 100644 --- a/cli/src/commands/search.rs +++ b/cli/src/commands/search.rs @@ -1,13 +1,15 @@ use anyhow::{Context, Result}; use log::info; -use reqwest::Client; use std::io::Write; -use crate::table::{self, IntoTableRow}; -use jm_client_core::util::{self, api}; +use crate::{ + table::{self, AsTableRow}, + util, +}; +use jm_client_core::JMClient; pub async fn run( - http: &Client, + client: &JMClient, query: String, user: Option, category: Option, @@ -15,21 +17,17 @@ pub async fn run( cat: bool, ) -> Result<()> { let (memes, ..) = tokio::try_join!( - api::memes( - http, - category.as_ref().map(String::clone), - user.as_ref().map(String::clone), - ), + async { client.get_memes().await.map_err(|e| e.into()) }, async { if let Some(u) = user.as_ref() { - util::assert_user_exists(http, u).await + util::assert_user_exists(client, u).await } else { Ok(()) } }, async { if let Some(c) = category.as_ref() { - util::assert_category_exists(http, c).await + util::assert_category_exists(client, c).await } else { Ok(()) } @@ -40,7 +38,12 @@ pub async fn run( info!("Starting search with query '{}'", query); - for meme in memes.iter() { + let memes = memes + .iter() + .filter(|m| category.as_ref().map(|c| &m.category == c).unwrap_or(true)) + .filter(|m| user.as_ref().map(|u| &m.user == u).unwrap_or(true)); + + for meme in memes { let file_name = meme.file_name()?; if let Some(score) = fuzzy_matcher::clangd::fuzzy_match(file_name, &query) { info!("Found matching meme '{}' with score {}", file_name, score); @@ -54,7 +57,7 @@ pub async fn run( let mut table = table::list_table(); for m in matches { - table.add_row(m.0.into_table_row()); + table.add_row(m.0.as_table_row()); } table.render().into_bytes() }, @@ -68,7 +71,7 @@ pub async fn run( .into_bytes(), (_, true) => { let url = &matches.first().context("No results found")?.0.link; - http.get(url).send().await?.bytes().await?.to_vec() + client.http.get(url).send().await?.bytes().await?.to_vec() }, }; diff --git a/cli/src/commands/up.rs b/cli/src/commands/up.rs index 86e0dd3..a538df4 100644 --- a/cli/src/commands/up.rs +++ b/cli/src/commands/up.rs @@ -1,25 +1,25 @@ -use crate::util::open_link; +use crate::util; use anyhow::Result; -use jm_client_core::{api::UpResp, util}; +use jm_client_core::{api::UpResp, JMClient}; use log::info; use reqwest::{ multipart::{Form, Part}, Body, - Client, }; use tokio::{fs::File, io::reader_stream}; pub async fn run( - http: &Client, + client: &JMClient, token: String, path: String, name: String, category: String, open: bool, ) -> Result<()> { - util::assert_category_exists(http, &category).await?; + util::assert_category_exists(client, &category).await?; - let res = http + let res = client + .http .post("https://data.tilera.xyz/api/jensmemes/upload") .multipart( Form::new() @@ -38,7 +38,8 @@ pub async fn run( .await?; let status = res.status(); - let res = util::api::try_deserialize_api_reponse::(&res.bytes().await?)?; + // TODO move into JMClient + let res = serde_json::from_slice::(&res.bytes().await?)?; println!("Server responded with code {}", status); @@ -48,7 +49,7 @@ pub async fn run( for f in res.files { if open { - open_link(&f).await?; + util::open_link(&f).await?; } else { println!("{}", f); } diff --git a/cli/src/commands/users.rs b/cli/src/commands/users.rs index 3f80c25..022732d 100644 --- a/cli/src/commands/users.rs +++ b/cli/src/commands/users.rs @@ -1,14 +1,13 @@ -use crate::table::{self, IntoTableRow}; +use crate::table::{self, AsTableRow}; use anyhow::Result; -use jm_client_core::util::api; -use reqwest::Client; +use jm_client_core::JMClient; -pub async fn run(http: &Client) -> Result<()> { - let users = api::users(http).await?; +pub async fn run(client: &JMClient) -> Result<()> { + let users = client.get_users().await?; let mut table = table::list_table(); - for u in users { - table.add_row(u.into_table_row()) + for u in &*users { + table.add_row(u.as_table_row()) } println!("{}", table.render()); diff --git a/cli/src/main.rs b/cli/src/main.rs index 014404e..05370a3 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,6 +1,5 @@ use anyhow::Result; -use jm_client_core::util::MemeSorting; -use reqwest::Client; +use jm_client_core::{util::MemeSorting, JMClient}; use structopt::StructOpt; mod commands; @@ -92,7 +91,7 @@ async fn main() -> Result<()> { env_logger::init(); let Opts { cmd } = Opts::from_args(); - let http = Client::new(); + let client = JMClient::new(); match cmd { Cmd::Up { @@ -103,23 +102,23 @@ async fn main() -> Result<()> { open, } => { let name = name.unwrap_or_else(|| file.clone()); - commands::up::run(&http, token, file, name, category, open).await?; + commands::up::run(&client, token, file, name, category, open).await?; }, - Cmd::Cats => commands::cats::run(&http).await?, + Cmd::Cats => commands::cats::run(&client).await?, Cmd::Search { query, user, category, firsturl, cat, - } => commands::search::run(&http, query, user, category, firsturl, cat).await?, + } => commands::search::run(&client, query, user, category, firsturl, cat).await?, Cmd::List { category, user, sort, fzf, - } => commands::list::run(&http, category, user, sort, fzf).await?, - Cmd::Users => commands::users::run(&http).await?, + } => commands::list::run(&client, category, user, sort, fzf).await?, + Cmd::Users => commands::users::run(&client).await?, } Ok(()) diff --git a/cli/src/table.rs b/cli/src/table.rs index ed87f8a..a4c4423 100644 --- a/cli/src/table.rs +++ b/cli/src/table.rs @@ -12,18 +12,18 @@ pub fn list_table<'a>() -> Table<'a> { .build() } -pub trait IntoTableRow { - fn into_table_row(&self) -> term_table::row::Row; +pub trait AsTableRow { + fn as_table_row(&self) -> term_table::row::Row; } -impl IntoTableRow for Category { - fn into_table_row(&self) -> Row<'_> { +impl AsTableRow for Category { + fn as_table_row(&self) -> Row<'_> { Row::new(vec![TableCell::new(&self.id), TableCell::new(&self.name)]) } } -impl IntoTableRow for Meme { - fn into_table_row(&self) -> Row<'_> { +impl AsTableRow for Meme { + fn as_table_row(&self) -> Row<'_> { Row::new(vec![ TableCell::new(&self.link), TableCell::new(&self.category), @@ -33,8 +33,8 @@ impl IntoTableRow for Meme { } } -impl IntoTableRow for User { - fn into_table_row(&self) -> Row<'_> { +impl AsTableRow for User { + fn as_table_row(&self) -> Row<'_> { Row::new(vec![ TableCell::new(&self.name), TableCell::new(&self.get_id().map(String::as_ref).unwrap_or("[No ID]")), diff --git a/cli/src/util.rs b/cli/src/util.rs index 67a4158..018b89a 100644 --- a/cli/src/util.rs +++ b/cli/src/util.rs @@ -1,5 +1,26 @@ +use anyhow::bail; +use jm_client_core::JMClient; use tokio::process::Command; +pub const NO_SUCH_CATEGORY_ERROR: &str = "The given Category does not exist!"; +pub const NO_SUCH_USER_ERROR: &str = "The given User does not exist!"; + +pub async fn assert_category_exists(client: &JMClient, cat: &str) -> anyhow::Result<()> { + if !client.get_cats().await?.iter().any(|c| c.id == cat) { + bail!(NO_SUCH_CATEGORY_ERROR); + } + + Ok(()) +} + +pub async fn assert_user_exists(client: &JMClient, user: &str) -> anyhow::Result<()> { + if !client.get_users().await?.iter().any(|u| u.name == user) { + bail!(NO_SUCH_USER_ERROR); + } + + Ok(()) +} + pub async fn open_link(url: &str) -> anyhow::Result<()> { match std::env::var_os("BROWSER") { Some(browser) => { diff --git a/gui/src/main.rs b/gui/src/main.rs index 3ae6f38..310030b 100644 --- a/gui/src/main.rs +++ b/gui/src/main.rs @@ -11,8 +11,7 @@ use druid::{ WidgetExt, WindowDesc, }; -use jm_client_core::api::Meme; -use reqwest::Client; +use jm_client_core::{api::Meme, JMClient}; pub(crate) mod util; @@ -20,8 +19,9 @@ const LIST_COLS: &[(&str, f64)] = &[("Link", 1000.), ("User", 50.)]; #[tokio::main] async fn main() -> anyhow::Result<()> { - let http = Client::new(); - let mut memes = jm_client_core::util::api::memes(&http, None, None) + let client = JMClient::new(); + let mut memes = client + .get_memes() .await? .iter() .map(|m| EqData(m.clone())) diff --git a/jm_client_core/src/api.rs b/jm_client_core/src/api.rs index 4e0545c..b33c720 100644 --- a/jm_client_core/src/api.rs +++ b/jm_client_core/src/api.rs @@ -1,5 +1,9 @@ use anyhow::{anyhow, Result}; -use serde::{Deserialize, Deserializer, de::{self, Visitor}}; +use serde::{ + de::{self, Visitor}, + Deserialize, + Deserializer, +}; #[derive(Deserialize, Debug, PartialEq, Eq, Clone)] pub struct UpResp { @@ -60,8 +64,8 @@ impl User { pub fn get_id(&self) -> Option<&String> { self.id .as_ref() - .or(self.tokenhash.as_ref()) - .or(self.userdir.as_ref()) + .or_else(|| self.tokenhash.as_ref()) + .or_else(|| self.userdir.as_ref()) } } @@ -92,7 +96,8 @@ where where E: de::Error, { - v.parse().map_err(|_| de::Error::invalid_value(de::Unexpected::Str(v), &self)) + v.parse() + .map_err(|_| de::Error::invalid_value(de::Unexpected::Str(v), &self)) } } diff --git a/jm_client_core/src/client.rs b/jm_client_core/src/client.rs new file mode 100644 index 0000000..2afd541 --- /dev/null +++ b/jm_client_core/src/client.rs @@ -0,0 +1,130 @@ +use crate::api::{Category, CatsResp, Meme, MemesResp, User, UsersResp}; +use log::info; +use once_cell::sync::OnceCell; +use reqwest::Url; +use serde::de::DeserializeOwned; +use std::sync::Arc; +use thiserror::Error; +use tokio::sync::Mutex; + +#[derive(Debug)] +pub struct JMClient { + pub http: reqwest::Client, + cache: Mutex, + endpoint: Url, +} + +macro_rules! init_cache { + ($cell:expr, $init_fn:expr) => {{ + let cell = &$cell; + Arc::clone(match cell.get() { + Some(x) => x, + None => { + let x = Arc::new($init_fn); + cell.get_or_init(|| x) + }, + }) + }}; +} + +impl JMClient { + pub fn new() -> Self { + Self::builder().build() + } + + pub fn builder() -> JMClientBuilder { + JMClientBuilder::default() + } + + async fn get_api_json T>( + &self, + cache: &OnceCell>, + endpoint: &str, + res_to_data: F, + ) -> Result, JMClientError> { + Ok(init_cache!(cache, { + info!("Requesting {} from server", endpoint); + let url = self.endpoint.join(endpoint)?; + let res = self.http.get(url).send().await?; + + let res = serde_json::from_slice(&res.bytes().await?)?; + res_to_data(res) + })) + } + + pub async fn get_cats(&self) -> Result>, JMClientError> { + self.get_api_json( + &self.cache.lock().await.cats, + "categories", + |r: CatsResp| r.categories, + ) + .await + } + + pub async fn get_memes(&self) -> Result>, JMClientError> { + self.get_api_json(&self.cache.lock().await.memes, "memes", |r: MemesResp| { + r.memes + }) + .await + } + + pub async fn get_users(&self) -> Result>, JMClientError> { + self.get_api_json(&self.cache.lock().await.users, "users", |r: UsersResp| { + r.users + }) + .await + } +} +impl Default for JMClient { + fn default() -> Self { + Self::new() + } +} + +#[derive(Debug, Error)] +pub enum JMClientError { + #[error("Error making http request: {0}")] + Http(#[from] reqwest::Error), + + #[error("Error deserializing JensMemes response: {0}")] + Deserialize(#[from] serde_json::Error), + + #[error("Failed parsing URL to make request to JensMemes: {0}")] + UrlParse(#[from] url::ParseError), +} + +#[derive(Debug, Default)] +struct Cache { + users: OnceCell>>, + cats: OnceCell>>, + memes: OnceCell>>, +} + +#[derive(Debug, Default)] +pub struct JMClientBuilder { + client: Option, + endpoint: Option, +} + +impl JMClientBuilder { + pub fn build(self) -> JMClient { + JMClient { + http: self.client.unwrap_or_else(reqwest::Client::new), + endpoint: self.endpoint.unwrap_or_else(|| { + // unwrapping is fine here, as the hardcoded input is known to work. + Url::parse(crate::util::consts::API_ENDPOINT).unwrap() + }), + cache: Mutex::new(Cache::default()), + } + } + + pub fn client(mut self, client: reqwest::Client) -> Self { + self.client = Some(client); + self + } + + pub fn endpoint(mut self, endpoint: impl Into) -> Self { + self.endpoint = Some(endpoint.into()); + self + } +} diff --git a/jm_client_core/src/lib.rs b/jm_client_core/src/lib.rs index c4755a6..d94352e 100644 --- a/jm_client_core/src/lib.rs +++ b/jm_client_core/src/lib.rs @@ -1,2 +1,5 @@ pub mod api; +pub mod client; pub mod util; + +pub use client::JMClient; diff --git a/jm_client_core/src/util.rs b/jm_client_core/src/util.rs index f3d61b1..14d3af0 100644 --- a/jm_client_core/src/util.rs +++ b/jm_client_core/src/util.rs @@ -1,150 +1,9 @@ use crate::api::Meme; use anyhow::bail; -use reqwest::Client; use std::str::FromStr; -#[macro_export] -macro_rules! init_once_cell { - ($cell:ident, $init_fn:expr) => { - match $cell.get() { - Some(x) => x, - None => { - let x = $init_fn; - $cell.get_or_init(|| x) - }, - } - }; -} - pub mod consts { - pub const API_ENDPOINT: &str = "https://api.tilera.xyz/jensmemes/v1"; - pub const NO_SUCH_CATEGORY_ERROR: &str = "The given Category does not exist!"; - pub const NO_SUCH_USER_ERROR: &str = "The given User does not exist!"; -} - -/// ways to communicyte with the JM API -pub mod api { - use super::consts; - use crate::api::{Category, CatsResp, Meme, MemesResp, User, UsersResp}; - use anyhow::Result; - use lazy_static::lazy_static; - use log::info; - use once_cell::sync::OnceCell; - use reqwest::Client; - use std::{ - collections::HashMap, - sync::{Arc, Mutex}, - }; - use thiserror::Error; - use url::Url; - - // cached api responses - static USERS: OnceCell> = OnceCell::new(); - static CATS: OnceCell> = OnceCell::new(); - lazy_static! { - // is this type long enough yet? - static ref MEMES: Mutex, Option), Arc>>> = - Mutex::new(HashMap::new()); - } - - #[derive(Error, Debug)] - #[error("Error Deserializing JSON api response: \n{json}\nError: {error}")] - pub struct ApiDeserializeError { - pub json: String, - #[source] - pub error: serde_json::Error, - } - - pub async fn cats(http: &Client) -> Result<&Vec> { - Ok(init_once_cell!(CATS, { - info!("Requesting categories from server"); - let res = http - .get(&format!("{}/categories", consts::API_ENDPOINT)) - .send() - .await?; - let cats = try_deserialize_api_reponse::(&res.bytes().await?)?; - cats.categories.into_iter().collect() - })) - } - - pub async fn memes<'a>( - http: &Client, - cat_filter: Option, - usr_filter: Option, - ) -> Result>> { - let filters = (cat_filter, usr_filter); - if let Some(m) = MEMES.lock().unwrap().get(&filters) { - return Ok(m.clone()); - } - - let mut url = Url::options().parse(&format!("{}/memes", consts::API_ENDPOINT))?; - let mut pairs = url.query_pairs_mut(); - - if let Some(cat) = filters.0.as_ref() { - pairs.append_pair("category", cat); - } - - if let Some(usr) = filters.1.as_ref() { - pairs.append_pair("user", usr); - } - - // drop required in order to move the URL into the request - drop(pairs); - - info!("Requesting memes from server"); - let res = http.get(url).send().await?; - let memes = try_deserialize_api_reponse::(&res.bytes().await?)?; - - Ok(MEMES - .lock() - .unwrap() - .entry(filters) - .or_insert(Arc::new(memes.memes)) - .clone()) - } - - pub async fn users(http: &Client) -> Result<&Vec> { - Ok(init_once_cell!(USERS, { - info!("Requesting users from server"); - let res = http - .get(&format!("{}/users", consts::API_ENDPOINT)) - .send() - .await?; - let users = try_deserialize_api_reponse::(&res.bytes().await?)?; - users.users - })) - } - - /// tries to deserialize `json` as `T`, and if it fails converts the error - /// to a `ApiDeserializeError` - pub fn try_deserialize_api_reponse<'a, T: serde::Deserialize<'a>>(json: &'a [u8]) -> Result { - let result = serde_json::from_slice::(&json); - result.map_err(|error| match std::str::from_utf8(json) { - Err(e) => e.into(), - Ok(json) => ApiDeserializeError { - json: json.into(), - error, - } - .into(), - }) - } - - #[cfg(test)] - mod tests { - use super::*; - - #[test] - fn api_deserialize_error() { - let incorrect_json = r#" - { - "foo": "bar" - } - "#; - - let result = try_deserialize_api_reponse::(incorrect_json.as_bytes()); - println!("{}", result.unwrap_err()); - } - } + pub const API_ENDPOINT: &str = "https://api.tilera.xyz/jensmemes/v1/"; } pub enum MemeSorting { @@ -191,16 +50,3 @@ impl FromStr for MemeSorting { } } } - -pub async fn assert_user_exists(http: &Client, user: &str) -> anyhow::Result<()> { - if !api::users(http).await?.iter().any(|u| u.name == user) { - bail!(consts::NO_SUCH_USER_ERROR); - } - Ok(()) -} -pub async fn assert_category_exists(http: &Client, cat: &str) -> anyhow::Result<()> { - if !api::cats(http).await?.iter().any(|c| c.id == cat) { - bail!(consts::NO_SUCH_CATEGORY_ERROR); - } - Ok(()) -}