diff --git a/Cargo.lock b/Cargo.lock index 8f7d0d5..f4e60c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -851,7 +851,7 @@ dependencies = [ "serde", "serde_json", "tokio", - "toml", + "toml_edit", "url", "uuid", "xxhash-rust", @@ -1745,15 +1745,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_spanned" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96426c9936fd7a0124915f9185ea1d20aa9445cc9821142f0a73bc9207a2e186" -dependencies = [ - "serde", -] - [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1949,37 +1940,19 @@ dependencies = [ "tracing", ] -[[package]] -name = "toml" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc1433177506450fe920e46a4f9812d0c211f5dd556da10e731a0a3dfa151f0" -dependencies = [ - "indexmap 2.0.2", - "serde", - "serde_spanned", - "toml_datetime", - "toml_edit", -] - [[package]] name = "toml_datetime" version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" -dependencies = [ - "serde", -] [[package]] name = "toml_edit" -version = "0.20.1" +version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca676d9ba1a322c1b64eb8045a5ec5c0cfb0c9d08e15e9ff622589ad5221c8fe" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" dependencies = [ "indexmap 2.0.2", - "serde", - "serde_spanned", "toml_datetime", "winnow", ] diff --git a/Cargo.toml b/Cargo.toml index e0c1291..dfb3d70 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,7 +34,7 @@ rustyline = { version = "12.0.0", default-features = false } serde = { version = "1.0.188", features = ["derive"] } serde_json = { version = "1.0.107", features = ["float_roundtrip", "preserve_order"] } tokio = { version = "1.32.0", features = ["parking_lot", "rt-multi-thread"] } -toml = { version = "0.8.0", features = ["preserve_order"] } +toml_edit = "0.20.2" url = { version = "2.4.1", features = ["serde"] } uuid = { version = "1.4.1", features = ["serde"] } xxhash-rust = { version = "0.8.7", features = ["xxh3", "const_xxh3"] } diff --git a/src/cli.rs b/src/cli.rs index 2f82678..f16613c 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -110,48 +110,28 @@ pub enum ConfigSubcommand { /// Print the location of the config file currently used by other hub /// commands Path, - /// Set the Hub GraphQL API endpoint - GraphqlEndpoint(ConfigGraphqlEndpoint), - /// Override or reset the Hub root endpoint - HubEndpoint(ConfigHubEndpoint), + /// Refresh the contents of the current config file, updating any + /// deprecated properties + Update, + /// Set the Hub API endpoint + ApiEndpoint(ConfigApiEndpoint), /// Read a new Hub API token from STDIN Token, } -/// Options for hub config graphql-endpoint +/// Options for hub config api-endpoint #[derive(clap::Args)] -pub struct ConfigGraphqlEndpoint { - /// Print the current GraphQL API endpoint +pub struct ConfigApiEndpoint { + /// Print the current API endpoint #[arg(long)] pub get: bool, - /// Specify the GraphQL API endpoint, required if not using a terminal, - /// otherwise STDIN is used as the default + /// Specify the API endpoint, required if not using a terminal, otherwise + /// STDIN is used as the default #[arg(required = !std::io::stdin().is_terminal(), conflicts_with("get"))] pub endpoint: Option, } -/// Options for hub config hub-endpoint -#[derive(clap::Args)] -pub struct ConfigHubEndpoint { - /// Print the current root Hub endpoint - #[arg(long)] - pub get: bool, - - /// Reset the endpoint override and infer it from the GraphQL API endpoint - #[arg(short, long, conflicts_with("get"))] - pub reset: bool, - - /// Override the root Hub endpoint, required if not using a terminal, - /// otherwise STDIN is used as the default - #[arg( - required = !std::io::stdin().is_terminal(), - conflicts_with("get"), - conflicts_with("reset"), - )] - pub endpoint: Option, -} - /// Options for hub airdrop #[derive(clap::Args)] pub struct Airdrop { diff --git a/src/commands/airdrop.rs b/src/commands/airdrop.rs index af2008b..8555551 100644 --- a/src/commands/airdrop.rs +++ b/src/commands/airdrop.rs @@ -135,8 +135,8 @@ pub fn run(config: &Config, cache: CacheConfig, args: Airdrop) -> Result<()> { let ctx = Context { // TODO: what should the correct path for this be? cache: Cache::load_sync(Path::new(".airdrops").join(drop_id.to_string()), cache)?, - graphql_endpoint: config.graphql_endpoint().clone(), - client: config.graphql_client()?, + graphql_endpoint: config.graphql_endpoint()?, + client: config.api_client()?, q: tx, stats: Arc::default(), }; diff --git a/src/commands/config.rs b/src/commands/config.rs index 1a8b0f4..7321e4f 100644 --- a/src/commands/config.rs +++ b/src/commands/config.rs @@ -6,7 +6,7 @@ use std::{ use anyhow::{Context, Result}; use crate::{ - cli::{Config as Opts, ConfigGraphqlEndpoint, ConfigHubEndpoint, ConfigSubcommand}, + cli::{Config as Opts, ConfigApiEndpoint, ConfigSubcommand}, config::{Config, ConfigLocation}, }; @@ -14,15 +14,9 @@ fn mutate_config( config_location: &ConfigLocation, mutate: impl FnOnce(&mut Config) -> Result<()>, ) -> Result<()> { - let config = config_location.load()?; - let mut next_config = config.clone(); - mutate(&mut next_config)?; - - if next_config != config { - next_config.save(config_location)?; - } - - Ok(()) + let mut config = config_location.load()?; + mutate(&mut config)?; + config.save(config_location) } pub fn run(config: &ConfigLocation, opts: Opts) -> Result<()> { @@ -34,8 +28,8 @@ pub fn run(config: &ConfigLocation, opts: Opts) -> Result<()> { println!("{}", canon.as_deref().unwrap_or(config.path()).display()); Ok(()) }, - ConfigSubcommand::GraphqlEndpoint(e) => mutate_config(config, |c| graphql_endpoint(c, e)), - ConfigSubcommand::HubEndpoint(e) => mutate_config(config, |c| hub_endpoint(c, e)), + ConfigSubcommand::Update => config.load()?.save(config), + ConfigSubcommand::ApiEndpoint(e) => mutate_config(config, |c| api_endpoint(c, e)), ConfigSubcommand::Token => mutate_config(config, token), } } @@ -54,51 +48,21 @@ where T::Err: fmt::Display { } } -fn graphql_endpoint(config: &mut Config, endpoint: ConfigGraphqlEndpoint) -> Result<()> { - let ConfigGraphqlEndpoint { get, endpoint } = endpoint; +fn api_endpoint(config: &mut Config, endpoint: ConfigApiEndpoint) -> Result<()> { + let ConfigApiEndpoint { get, endpoint } = endpoint; if get { - println!("{}", config.graphql_endpoint()); + println!("{}", config.api_endpoint()); return Ok(()); } let endpoint = if let Some(e) = endpoint { e.parse().context("Invalid endpoint URL")? } else { - read_insecure("Enter new GraphQL endpoint: ", "Invalid URL")? - }; - - config.set_graphql_endpoint(endpoint); - - Ok(()) -} - -fn hub_endpoint(config: &mut Config, endpoint: ConfigHubEndpoint) -> Result<()> { - let ConfigHubEndpoint { - get, - reset, - endpoint, - } = endpoint; - - if get { - println!( - "{}", - config - .hub_endpoint() - .context("Error computing root Hub endpoint from GraphQL endpoint")? - ); - return Ok(()); - } - - let endpoint = if reset { - None - } else if let Some(e) = endpoint { - Some(e.parse().context("Invalid endpoint URL")?) - } else { - Some(read_insecure("Enter new Hub endpoint: ", "Invalid URL")?) + read_insecure("Enter new API endpoint: ", "Invalid URL")? }; - config.set_hub_endpoint(endpoint); + config.set_api_endpoint(endpoint); Ok(()) } diff --git a/src/commands/upload_drop.rs b/src/commands/upload_drop.rs index dd957db..45382fc 100644 --- a/src/commands/upload_drop.rs +++ b/src/commands/upload_drop.rs @@ -40,12 +40,10 @@ use crate::{ config::Config, }; -type UploadResponse = Vec; - #[derive(Debug, Serialize, Deserialize)] -struct UploadedAsset { - name: String, - url: Url, +struct UploadResponse { + uri: Url, + cid: String, } #[derive(GraphQLQuery)] @@ -196,9 +194,9 @@ pub fn run(config: &Config, cache: CacheConfig, args: UploadDrop) -> Result<()> .into_boxed_slice() .into(), drop_id, - graphql_endpoint: config.graphql_endpoint().clone(), + graphql_endpoint: config.graphql_endpoint()?, upload_endpoint: config.upload_endpoint()?, - client: config.graphql_client()?, + client: config.api_client()?, q: tx, stats: Arc::default(), }; @@ -475,7 +473,7 @@ impl UploadAssetJob { .to_string_lossy() .into_owned(); - let mut uploads = ctx + let upload = ctx .client .post(ctx.upload_endpoint) .multipart( @@ -499,29 +497,20 @@ impl UploadAssetJob { .await .with_context(|| { format!("Error deserializing upload response JSON for {path:?}") - })? - .into_iter(); - - if uploads.len() > 1 { - warn!("Trailing values in response data for {path:?}"); - } - - let upload = uploads - .find(|u| u.name == name) - .with_context(|| format!("Missing upload response data for {path:?}"))?; + })?; ctx.stats.uploaded_assets.increment(); info!("Successfully uploaded {path:?}"); cache .set_named(path.clone(), ck, AssetUpload { - url: upload.url.to_string(), + url: upload.uri.to_string(), }) .await .map_err(|e| warn!("{e:?}")) .ok(); - dest_url = upload.url; + dest_url = upload.uri; } rewrites diff --git a/src/config.rs b/src/config.rs index c310957..2a8287e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,16 +1,15 @@ use std::{ - borrow::Cow, fs, io::ErrorKind, path::{Path, PathBuf}, + sync::OnceLock, }; use anyhow::{anyhow, Context, Result}; use directories::ProjectDirs; -use itertools::Itertools; use log::warn; use reqwest::Client; -use serde::{Deserialize, Serialize}; +use toml_edit::{Document, Item, Table}; use url::Url; pub const LOCAL_NAME: &str = ".hub-config.toml"; @@ -31,11 +30,21 @@ fn swap_path(mut path: PathBuf) -> Option { Some(path) } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, Copy)] enum ConfigMode { ExplicitWrite, ExplicitRead, - Implicit, + Implicit(bool), +} + +impl ConfigMode { + fn writable(self) -> bool { + match self { + Self::ExplicitWrite => true, + Self::ExplicitRead => false, + Self::Implicit(w) => w, + } + } } pub struct ConfigLocation { @@ -48,7 +57,7 @@ impl ConfigLocation { let mode = match (&path, writable) { (Some(_), true) => ConfigMode::ExplicitWrite, (Some(_), false) => ConfigMode::ExplicitRead, - (None, _) => ConfigMode::Implicit, + (None, w) => ConfigMode::Implicit(w), }; let path = path.map_or_else( || { @@ -78,77 +87,255 @@ impl ConfigLocation { pub fn path(&self) -> &Path { &self.path } } -#[derive(Serialize, Deserialize)] -#[serde(rename_all = "kebab-case")] -struct ConfigFile<'a> { - hub: Cow<'a, Config>, +mod dirty { + use std::{mem, ops}; + + use toml_edit::Value; + + #[derive(Debug, Clone, Copy, Default)] + pub struct Dirty { + dirty: bool, + value: T, + } + + impl From for Dirty { + #[inline] + fn from(value: T) -> Self { + Self { + dirty: false, + value, + } + } + } + + impl ops::Deref for Dirty { + type Target = T; + + #[inline] + fn deref(&self) -> &Self::Target { &self.value } + } + + impl ops::DerefMut for Dirty { + #[inline] + fn deref_mut(&mut self) -> &mut Self::Target { + self.dirty = true; + &mut self.value + } + } + + impl Dirty { + pub fn write_entry<'a, 'b, U: Into + 'b, F: FnOnce(&'b T) -> U>( + &'b mut self, + item: toml_edit::Entry<'a>, + map: F, + ) where + T: 'b, + { + use toml_edit::Entry::{Occupied, Vacant}; + + let true = mem::replace(&mut self.dirty, false) else { + return; + }; + + let val = toml_edit::value(map(&self.value)); + match item { + Occupied(o) => { + *o.into_mut() = val; + }, + Vacant(v) => { + v.insert(val); + }, + } + } + } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case")] +use dirty::Dirty; + +#[derive(Debug, Clone)] pub struct Config { - graphql_endpoint: Url, - hub_endpoint: Option, - token: String, + doc: Document, + api_endpoint: Dirty, + token: Dirty, +} + +impl Config { + const DEFAULT_TOKEN: &'static str = ""; + + fn default_api_endpoint() -> &'static Url { + static ENDPOINT: OnceLock = OnceLock::new(); + ENDPOINT.get_or_init(|| "https://api.holaplex.com/".try_into().unwrap()) + } } impl Default for Config { fn default() -> Self { Self { - graphql_endpoint: "https://api.holaplex.com/graphql".try_into().unwrap(), - hub_endpoint: None, - token: String::default(), + doc: Document::default(), + api_endpoint: Self::default_api_endpoint().clone().into(), + token: Self::DEFAULT_TOKEN.to_owned().into(), } } } impl Config { + const ROOT_NAME: &'static str = "hub"; + const API_ENDPOINT_NAME: &'static str = "api-endpoint"; + const TOKEN_NAME: &'static str = "token"; + + fn guess_api_from_graphql(url: &str) -> Option { + let mut url = Url::parse(url).ok()?; + let segs = url.path_segments()?; + + if segs.last()? != "graphql" { + return None; + } + + url.path_segments_mut().ok()?.pop(); + + Some(url) + } + pub fn load(location: &ConfigLocation) -> Result { let text = match fs::read_to_string(&location.path) { Ok(s) => s, Err(e) - if e.kind() == ErrorKind::NotFound && location.mode != ConfigMode::ExplicitRead => + if e.kind() == ErrorKind::NotFound && !matches!(location.mode, ConfigMode::ExplicitRead) => { return Ok(Self::default()); }, Err(e) => return Err(e).context("Error reading Hub config file"), }; - let ConfigFile { hub } = toml::from_str(&text).context("Error parsing Hub config")?; + let doc: Document = text.parse().context("Error parsing Hub config")?; + + // TODO: try-block hack + Ok(doc) + .and_then(|mut doc| { + let mut api_endpoint = None; + let mut api_guess = None; + let mut token = None; + + if let Some(root) = doc.get_mut(Self::ROOT_NAME) { + let root = root.as_table_mut().ok_or("Expected [hub] to be a table")?; + + if let Some(e) = root.get(Self::API_ENDPOINT_NAME) { + api_endpoint = Some( + e.as_str() + .and_then(|e| Url::parse(e).ok()) + .ok_or("Expected hub.api-endpoint to be a valid URL")?, + ); + } + + if let Some(t) = root.get(Self::TOKEN_NAME) { + token = Some( + t.as_str() + .ok_or("Expected hub.token to be a string")? + .to_owned(), + ); + } + + { + let no_write = !location.mode.writable(); + + if let Some(e) = root.remove("graphql-endpoint") { + if no_write { + warn!( + "The graphql-endpoint configuration property is deprecated. \ + Run `hub config update` to update your configuration file." + ); + } + + let endpoint = e.as_str().and_then(Self::guess_api_from_graphql); + + match (endpoint, &api_endpoint) { + (Some(e), Some(a)) if e == *a => (), + (Some(_), Some(_)) => warn!( + "Conflicting API endpoints defined by graphql-endpoint and \ + api-endpoint in your config. Ignoring graphql-endpoint \ + property." + ), + (e, None) => api_guess = e, + (None, _) => warn!( + "Unable to infer api-endpoint property from graphql-endpoint \ + property. Ignoring graphql-endpoint." + ), + } + } + + if root.remove("hub-endpoint").is_some() && no_write { + warn!( + "The hub-endpoint configuration property is deprecated and will \ + be ignored. Run `hub config update` to update your \ + configuration file." + ); + } + } + } + + let mut api_endpoint: Dirty<_> = api_endpoint + .unwrap_or_else(|| Self::default_api_endpoint().clone()) + .into(); - Ok(hub.into_owned()) + if let Some(guess) = api_guess { + *api_endpoint = guess; + } + + Ok(Self { + doc, + api_endpoint, + token: token.unwrap_or_else(|| Self::DEFAULT_TOKEN.into()).into(), + }) + }) + .map_err(|e: &str| anyhow!("Error parsing Hub config: {e}")) } - pub fn save(&self, location: &ConfigLocation) -> Result<()> { - if location.mode == ConfigMode::Implicit { + pub fn save(&mut self, location: &ConfigLocation) -> Result<()> { + assert!(location.mode.writable(), "Config::save called with a non-writable location!"); + + if matches!(location.mode, ConfigMode::Implicit(_)) { if let Some(dir) = location.path.parent() { fs::create_dir_all(dir).context("Error creating Hub config directory")?; } } - let text = toml::to_string_pretty(&ConfigFile { - hub: Cow::Borrowed(self), - }) - .context("Error serializing Hub config")?; + // TODO: try-block hack + Ok(()) + .and_then(|()| { + let Self { + doc, + api_endpoint, + token, + } = self; + + let root = doc + .entry(Self::ROOT_NAME) + .or_insert(Item::Table(Table::default())) + .as_table_mut() + .ok_or("Expected [hub] to be a table")?; + + api_endpoint.write_entry(root.entry(Self::API_ENDPOINT_NAME), Url::as_str); + token.write_entry(root.entry(Self::TOKEN_NAME), |u| u); + + Ok(()) + }) + .map_err(|e: &str| anyhow!("Error updating Hub config: {e}"))?; let swap = swap_path(location.path.clone()).context("Invalid Hub config path")?; - fs::write(&swap, text.as_bytes()).context("Error writing Hub config swapfile")?; + fs::write(&swap, self.doc.to_string()).context("Error writing Hub config swapfile")?; fs::rename(swap, &location.path).context("Error writing Hub config file")?; Ok(()) } #[inline] - pub fn set_graphql_endpoint(&mut self, endpoint: Url) { self.graphql_endpoint = endpoint; } - - #[inline] - pub fn set_hub_endpoint(&mut self, endpoint: Option) { self.hub_endpoint = endpoint; } + pub fn set_api_endpoint(&mut self, endpoint: Url) { *self.api_endpoint = endpoint; } #[inline] - pub fn set_token(&mut self, token: String) { self.token = token; } + pub fn set_token(&mut self, token: String) { *self.token = token; } - pub fn graphql_client_builder(&self) -> Result { + pub fn api_client_builder(&self) -> Result { Ok(reqwest::ClientBuilder::default() .gzip(true) .deflate(true) @@ -156,7 +343,7 @@ impl Config { .default_headers( [( reqwest::header::AUTHORIZATION, - (&self.token) + (&*self.token) .try_into() .context("API token is not a valid HTTP header")?, )] @@ -166,62 +353,24 @@ impl Config { } #[inline] - pub fn graphql_client(&self) -> Result { - self.graphql_client_builder()? + pub fn api_client(&self) -> Result { + self.api_client_builder()? .build() .context("Error building HTTP client") } - pub fn hub_endpoint(&self) -> Result> { - self.hub_endpoint.as_ref().map(Cow::Borrowed).map_or_else( - || { - // TODO: try-block hack - Some(()) - .and_then(|()| { - let mut url = self.graphql_endpoint.clone(); - - let host = url.host_str()?; - let mut any_match = false; - - let host = host - .split('.') - .map(|s| { - if !any_match && s == "api" { - any_match = true; - "hub" - } else { - s - } - }) - .join("."); - - url.set_host(Some(any_match.then_some(host.as_ref())?)) - .ok()?; - - if let Some("graphql") = url.path_segments()?.last() { - let mut segs = url.path_segments_mut().unwrap(); - segs.pop(); - segs.push("api"); - } + #[inline] + pub fn api_endpoint(&self) -> &Url { &self.api_endpoint } - Some(Cow::Owned(url)) - }) - .context( - "Unable to infer Hub root endpoint from Hub GraphQL API endpoint, \ - consider setting hub-endpoint in your config", - ) - }, - Ok, - ) + pub fn graphql_endpoint(&self) -> Result { + self.api_endpoint + .join("graphql") + .context("Invalid Hub API endpoint") } - pub fn graphql_endpoint(&self) -> &Url { &self.graphql_endpoint } - pub fn upload_endpoint(&self) -> Result { - let mut url = self.hub_endpoint()?.into_owned(); - url.path_segments_mut() - .map_err(|()| anyhow!("Invalid Hub endpoint"))? - .push("uploads"); - Ok(url) + self.api_endpoint + .join("uploads") + .context("Invalid Hub API endpoint") } }