diff --git a/docs/API.md b/docs/API.md index 0d8effd..c6b2560 100644 --- a/docs/API.md +++ b/docs/API.md @@ -2,6 +2,12 @@ This document describes all available API endpoints for the Waterfalls server, which provides blockchain data indexing and querying capabilities for Bitcoin and Elements/Liquid networks. +## Response Behavior + +### HEAD Requests + +All GET endpoints also support HEAD requests. A HEAD response has the same status and headers as the corresponding GET response, including `Content-Length`, but has an empty body. + ## Waterfalls Endpoints These endpoints provide transaction history and UTXO data for descriptors or addresses. Available in both JSON and CBOR formats. diff --git a/src/server/route.rs b/src/server/route.rs index 1cfab05..8eaef69 100644 --- a/src/server/route.rs +++ b/src/server/route.rs @@ -12,8 +12,8 @@ use elements::BlockHash; use futures_util::{stream, StreamExt}; use http_body_util::{combinators::BoxBody, BodyExt, Full, Limited, StreamBody}; use hyper::{ - body::{Bytes, Frame, Incoming}, - header::{self, CACHE_CONTROL, CONTENT_TYPE}, + body::{Body, Bytes, Frame, Incoming}, + header::{self, CACHE_CONTROL, CONTENT_LENGTH, CONTENT_TYPE}, Method, Request, Response, StatusCode, }; use prometheus::Encoder; @@ -74,7 +74,12 @@ pub async fn route( ) -> Result { let is_testnet_or_regtest = !matches!(network, Network::Liquid | Network::Bitcoin); log::debug!("---> {req:?}"); - let res = match (req.method(), req.uri().path(), req.uri().query()) { + let method = if req.method() == Method::HEAD { + Method::GET + } else { + req.method().clone() + }; + let res = match (&method, req.uri().path(), req.uri().query()) { (&Method::GET, "/v1/server_recipient", None) => { str_resp(state.key.to_public().to_string(), StatusCode::OK) } @@ -536,6 +541,26 @@ fn any_resp( builder.body(full_body(bytes)).map_err(|_| Error::Other) } +fn strip_head_body(mut response: Resp) -> Resp { + if let Some(content_length) = response.body().size_hint().exact() { + response.headers_mut().insert( + CONTENT_LENGTH, + content_length + .to_string() + .parse() + .expect("content length is a valid header value"), + ); + } + *response.body_mut() = empty_body(); + response +} + +fn empty_body() -> RespBody { + BodyExt::boxed(StreamBody::new(stream::empty::< + Result, Infallible>, + >())) +} + fn full_body(bytes: Vec) -> RespBody { Full::new(Bytes::from(bytes)) .map_err(|never| match never {}) @@ -1300,6 +1325,7 @@ pub async fn infallible_route( network: Network, add_cors: bool, ) -> Result { + let is_head = req.method() == Method::HEAD; let mut response = match route(state, client, req, network).await { Ok(r) => r, Err(e) => error_resp(error_status(&e), &e), @@ -1311,7 +1337,7 @@ pub async fn infallible_route( headers.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*".parse().unwrap()); headers.insert( header::ACCESS_CONTROL_ALLOW_METHODS, - "GET, POST, OPTIONS".parse().unwrap(), + "GET, HEAD, POST, OPTIONS".parse().unwrap(), ); headers.insert( header::ACCESS_CONTROL_ALLOW_HEADERS, @@ -1319,6 +1345,10 @@ pub async fn infallible_route( ); } + if is_head { + response = strip_head_body(response); + } + Ok(response) } diff --git a/tests/integration.rs b/tests/integration.rs index a99e121..afd0319 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -608,6 +608,34 @@ async fn test_fetch_client_local_regtest(client: FetchClient, network: Network) assert!(fee_estimates.values().all(|&f| f > 0.0)); } +#[cfg(feature = "test_env")] +async fn head_matches_get(test_env: &waterfalls::test_env::TestEnv) { + let http_client = reqwest::Client::new(); + let build_info_url = format!("{}/v1/build_info", test_env.base_url()); + + let get_response = http_client.get(&build_info_url).send().await.unwrap(); + assert_eq!(get_response.status(), reqwest::StatusCode::OK); + let mut get_headers = get_response.headers().clone(); + let get_body = get_response.bytes().await.unwrap(); + + let head_response = http_client.head(&build_info_url).send().await.unwrap(); + assert_eq!(head_response.status(), reqwest::StatusCode::OK); + let mut head_headers = head_response.headers().clone(); + let head_body = head_response.bytes().await.unwrap(); + assert!(head_body.is_empty()); + let content_length = head_headers + .get(reqwest::header::CONTENT_LENGTH) + .unwrap() + .to_str() + .unwrap() + .parse::() + .unwrap(); + assert_eq!(content_length, get_body.len()); + get_headers.remove(reqwest::header::DATE); + head_headers.remove(reqwest::header::DATE); + assert_eq!(head_headers, get_headers); +} + #[cfg(feature = "test_env")] async fn do_test(test_env: waterfalls::test_env::TestEnv) { use bitcoin::sign_message::MessageSignature; @@ -628,6 +656,8 @@ async fn do_test(test_env: waterfalls::test_env::TestEnv) { let blinding = "slip77(9c8e4f05c7711a98c838be228bcb84924d4570ca53f35fa1c793e58841d47023)"; let desc_str = format!("ct({blinding},{single_bitcoin_desc})"); // we use a non-multipath to generate addresses + head_matches_get(&test_env).await; + let result = client.waterfalls_v2(&bitcoin_desc).await.unwrap().0; assert_eq!(result.page, 0); assert_eq!(result.txs_seen.len(), 2);