diff --git a/Cargo.lock b/Cargo.lock index d74ac94a20..0089ed5dda 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -295,9 +295,9 @@ checksum = "ecc7ab41815b3c653ccd2978ec3255c81349336702dfdf62ee6f7069b12a3aae" [[package]] name = "async-trait" -version = "0.1.71" +version = "0.1.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a564d521dd56509c4c47480d00b80ee55f7e385ae48db5744c67ad50c92d2ebf" +checksum = "cc6dde6e4ed435a4c1ee4e73592f5ba9da2151af10076cc04858746af9352d09" dependencies = [ "proc-macro2", "quote", @@ -2003,6 +2003,7 @@ name = "ord" version = "0.8.1" dependencies = [ "anyhow", + "async-trait", "axum", "axum-server", "base64 0.21.2", diff --git a/Cargo.toml b/Cargo.toml index e4ea445979..eb4e6b6498 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ members = [".", "test-bitcoincore-rpc"] [dependencies] anyhow = { version = "1.0.56", features = ["backtrace"] } +async-trait = "0.1.72" axum = { version = "0.6.1", features = ["headers"] } axum-server = "0.5.0" base64 = "0.21.0" diff --git a/src/epoch.rs b/src/epoch.rs index 7918191574..328b09af32 100644 --- a/src/epoch.rs +++ b/src/epoch.rs @@ -1,6 +1,6 @@ use super::*; -#[derive(Copy, Clone, Eq, PartialEq, Debug, Display, PartialOrd)] +#[derive(Copy, Clone, Eq, PartialEq, Debug, Display, Serialize, PartialOrd)] pub(crate) struct Epoch(pub(crate) u64); impl Epoch { diff --git a/src/height.rs b/src/height.rs index e2a3e20749..75ac7ec8ab 100644 --- a/src/height.rs +++ b/src/height.rs @@ -1,6 +1,6 @@ use super::*; -#[derive(Copy, Clone, Debug, Display, FromStr, Ord, Eq, PartialEq, PartialOrd)] +#[derive(Copy, Clone, Debug, Display, FromStr, Ord, Eq, Serialize, PartialEq, PartialOrd)] pub(crate) struct Height(pub(crate) u64); impl Height { diff --git a/src/index.rs b/src/index.rs index 05092ec223..1e1cf70405 100644 --- a/src/index.rs +++ b/src/index.rs @@ -464,6 +464,10 @@ impl Index { Ok(()) } + pub(crate) fn is_json_api_enabled(&self) -> bool { + self.options.enable_json_api + } + pub(crate) fn is_reorged(&self) -> bool { self.reorged.load(atomic::Ordering::Relaxed) } diff --git a/src/lib.rs b/src/lib.rs index 64a828a0f0..1ef8d220ab 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -108,19 +108,19 @@ mod fee_rate; mod height; mod index; mod inscription; -mod inscription_id; +pub mod inscription_id; mod media; mod object; mod options; mod outgoing; mod page_config; -mod rarity; +pub mod rarity; mod representation; -mod sat; +pub mod sat; mod sat_point; pub mod subcommand; mod tally; -mod templates; +pub mod templates; mod wallet; type Result = std::result::Result; diff --git a/src/options.rs b/src/options.rs index 20841e911b..7d2e5ef8a2 100644 --- a/src/options.rs +++ b/src/options.rs @@ -54,6 +54,8 @@ pub(crate) struct Options { pub(crate) testnet: bool, #[clap(long, default_value = "ord", help = "Use wallet named .")] pub(crate) wallet: String, + #[clap(long, short, help = "Enable JSON API.")] + pub(crate) enable_json_api: bool, } impl Options { diff --git a/src/subcommand/server.rs b/src/subcommand/server.rs index 52b3985d76..d33290c4e3 100644 --- a/src/subcommand/server.rs +++ b/src/subcommand/server.rs @@ -1,5 +1,6 @@ use { self::{ + accept_json::AcceptJson, deserialize_from_str::DeserializeFromStr, error::{OptionExt, ServerError, ServerResult}, }, @@ -8,11 +9,11 @@ use { crate::templates::{ BlockHtml, ClockSvg, HomeHtml, InputHtml, InscriptionHtml, InscriptionsHtml, OutputHtml, PageContent, PageHtml, PreviewAudioHtml, PreviewImageHtml, PreviewPdfHtml, PreviewTextHtml, - PreviewUnknownHtml, PreviewVideoHtml, RangeHtml, RareTxt, SatHtml, TransactionHtml, + PreviewUnknownHtml, PreviewVideoHtml, RangeHtml, RareTxt, SatHtml, SatJson, TransactionHtml, }, axum::{ body, - extract::{Extension, Path, Query}, + extract::{Extension, Json, Path, Query}, headers::UserAgent, http::{header, HeaderMap, HeaderValue, StatusCode, Uri}, response::{IntoResponse, Redirect, Response}, @@ -36,6 +37,7 @@ use { }, }; +mod accept_json; mod error; enum BlockQuery { @@ -382,18 +384,43 @@ impl Server { Extension(page_config): Extension>, Extension(index): Extension>, Path(DeserializeFromStr(sat)): Path>, - ) -> ServerResult> { + accept_json: AcceptJson, + ) -> ServerResult { let satpoint = index.rare_sat_satpoint(sat)?; - - Ok( + let blocktime = index.block_time(sat.height())?; + let inscriptions = index.get_inscription_ids_by_sat(sat)?; + Ok(if accept_json.0 { + if index.is_json_api_enabled() { + Json(SatJson { + number: sat.0, + decimal: sat.decimal().to_string(), + degree: sat.degree().to_string(), + name: sat.name(), + block: sat.height().0, + cycle: sat.cycle(), + epoch: sat.epoch().0, + period: sat.period(), + offset: sat.third(), + rarity: sat.rarity(), + percentile: sat.percentile(), + satpoint, + timestamp: blocktime.timestamp().to_string(), + inscriptions, + }) + .into_response() + } else { + StatusCode::NOT_ACCEPTABLE.into_response() + } + } else { SatHtml { sat, satpoint, - blocktime: index.block_time(sat.height())?, - inscriptions: index.get_inscription_ids_by_sat(sat)?, + blocktime, + inscriptions, } - .page(page_config, index.has_sat_index()?), - ) + .page(page_config, index.has_sat_index()?) + .into_response() + }) } async fn ordinal(Path(sat): Path) -> Redirect { diff --git a/src/subcommand/server/accept_json.rs b/src/subcommand/server/accept_json.rs new file mode 100644 index 0000000000..e01edf29c2 --- /dev/null +++ b/src/subcommand/server/accept_json.rs @@ -0,0 +1,24 @@ +use super::*; + +pub(crate) struct AcceptJson(pub(crate) bool); + +#[async_trait::async_trait] +impl axum::extract::FromRequestParts for AcceptJson +where + S: Send + Sync, +{ + type Rejection = (); + + async fn from_request_parts( + parts: &mut http::request::Parts, + _state: &S, + ) -> Result { + Ok(Self( + parts + .headers + .get("accept") + .map(|value| value == "application/json") + .unwrap_or_default(), + )) + } +} diff --git a/src/templates.rs b/src/templates.rs index 7a8858a869..e4f008f6a8 100644 --- a/src/templates.rs +++ b/src/templates.rs @@ -16,7 +16,7 @@ pub(crate) use { }, range::RangeHtml, rare::RareTxt, - sat::SatHtml, + sat::{SatHtml, SatJson}, transaction::TransactionHtml, }; @@ -31,7 +31,7 @@ mod output; mod preview; mod range; mod rare; -mod sat; +pub mod sat; mod transaction; #[derive(Boilerplate)] diff --git a/src/templates/sat.rs b/src/templates/sat.rs index fd1c5bab7d..c42b839750 100644 --- a/src/templates/sat.rs +++ b/src/templates/sat.rs @@ -8,6 +8,24 @@ pub(crate) struct SatHtml { pub(crate) inscriptions: Vec, } +#[derive(Debug, PartialEq, Serialize, Deserialize)] +pub struct SatJson { + pub number: u64, + pub decimal: String, + pub degree: String, + pub name: String, + pub block: u64, + pub cycle: u64, + pub epoch: u64, + pub period: u64, + pub offset: u64, + pub rarity: Rarity, + pub percentile: String, + pub satpoint: Option, + pub timestamp: String, + pub inscriptions: Vec, +} + impl PageContent for SatHtml { fn title(&self) -> String { format!("Sat {}", self.sat) diff --git a/tests/json_api.rs b/tests/json_api.rs new file mode 100644 index 0000000000..3cbba92188 --- /dev/null +++ b/tests/json_api.rs @@ -0,0 +1,86 @@ +use { + super::*, ord::inscription_id::InscriptionId, ord::rarity::Rarity, ord::templates::sat::SatJson, + ord::SatPoint, +}; + +#[test] +fn get_sat_without_sat_index() { + let rpc_server = test_bitcoincore_rpc::spawn(); + + let response = TestServer::spawn_with_args(&rpc_server, &["--enable-json-api"]) + .json_request("/sat/2099999997689999"); + + assert_eq!(response.status(), StatusCode::OK); + + let mut sat_json: SatJson = serde_json::from_str(&response.text().unwrap()).unwrap(); + + // this is a hack to ignore the timestamp, since it changes for every request + sat_json.timestamp = "".into(); + + pretty_assert_eq!( + sat_json, + SatJson { + number: 2099999997689999, + decimal: "6929999.0".into(), + degree: "5°209999′1007″0‴".into(), + name: "a".into(), + block: 6929999, + cycle: 5, + epoch: 32, + period: 3437, + offset: 0, + rarity: Rarity::Uncommon, + percentile: "100%".into(), + satpoint: None, + timestamp: "".into(), + inscriptions: vec![], + } + ) +} + +#[test] +fn get_sat_with_inscription_and_sat_index() { + let rpc_server = test_bitcoincore_rpc::spawn(); + + create_wallet(&rpc_server); + + let Inscribe { reveal, .. } = inscribe(&rpc_server); + let inscription_id = InscriptionId::from(reveal); + + let response = TestServer::spawn_with_args(&rpc_server, &["--index-sats", "--enable-json-api"]) + .json_request(format!("/sat/{}", 50 * COIN_VALUE)); + + assert_eq!(response.status(), StatusCode::OK); + + let sat_json: SatJson = serde_json::from_str(&response.text().unwrap()).unwrap(); + + pretty_assert_eq!( + sat_json, + SatJson { + number: 50 * COIN_VALUE, + decimal: "1.0".into(), + degree: "0°1′1″0‴".into(), + name: "nvtcsezkbth".into(), + block: 1, + cycle: 0, + epoch: 0, + period: 0, + offset: 0, + rarity: Rarity::Uncommon, + percentile: "0.00023809523835714296%".into(), + satpoint: Some(SatPoint::from_str(&format!("{}:{}:{}", reveal, 0, 0)).unwrap()), + timestamp: "1970-01-01 00:00:01 UTC".into(), + inscriptions: vec![inscription_id], + } + ) +} + +#[test] +fn json_request_fails_when_not_enabled() { + let rpc_server = test_bitcoincore_rpc::spawn(); + + let response = + TestServer::spawn_with_args(&rpc_server, &[]).json_request("/sat/2099999997689999"); + + assert_eq!(response.status(), StatusCode::NOT_ACCEPTABLE); +} diff --git a/tests/lib.rs b/tests/lib.rs index cd9f4dfab1..6ad150a58e 100644 --- a/tests/lib.rs +++ b/tests/lib.rs @@ -74,18 +74,20 @@ fn create_wallet(rpc_server: &test_bitcoincore_rpc::Handle) { } mod command_builder; +mod expected; +mod test_server; + mod core; mod epochs; -mod expected; mod find; mod index; mod info; +mod json_api; mod list; mod parse; mod server; mod subsidy; mod supply; -mod test_server; mod traits; mod version; mod wallet; diff --git a/tests/test_server.rs b/tests/test_server.rs index e068f9b698..098aa51d17 100644 --- a/tests/test_server.rs +++ b/tests/test_server.rs @@ -60,19 +60,7 @@ impl TestServer { } pub(crate) fn assert_response_regex(&self, path: impl AsRef, regex: impl AsRef) { - let client = Client::new(&self.rpc_url, Auth::None).unwrap(); - let chain_block_count = client.get_block_count().unwrap() + 1; - - for i in 0.. { - let response = reqwest::blocking::get(self.url().join("/blockcount").unwrap()).unwrap(); - assert_eq!(response.status(), StatusCode::OK); - if response.text().unwrap().parse::().unwrap() == chain_block_count { - break; - } else if i == 20 { - panic!("index failed to synchronize with chain"); - } - thread::sleep(Duration::from_millis(25)); - } + self.sync_server(); let response = reqwest::blocking::get(self.url().join(path.as_ref()).unwrap()).unwrap(); assert_eq!(response.status(), StatusCode::OK); @@ -80,6 +68,24 @@ impl TestServer { } pub(crate) fn request(&self, path: impl AsRef) -> Response { + self.sync_server(); + + reqwest::blocking::get(self.url().join(path.as_ref()).unwrap()).unwrap() + } + + pub(crate) fn json_request(&self, path: impl AsRef) -> Response { + self.sync_server(); + + let client = reqwest::blocking::Client::new(); + + client + .get(self.url().join(path.as_ref()).unwrap()) + .header(reqwest::header::ACCEPT, "application/json") + .send() + .unwrap() + } + + fn sync_server(&self) { let client = Client::new(&self.rpc_url, Auth::None).unwrap(); let chain_block_count = client.get_block_count().unwrap() + 1; @@ -93,8 +99,6 @@ impl TestServer { } thread::sleep(Duration::from_millis(25)); } - - reqwest::blocking::get(self.url().join(path.as_ref()).unwrap()).unwrap() } }