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
6 changes: 6 additions & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
38 changes: 34 additions & 4 deletions src/server/route.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -74,7 +74,12 @@ pub async fn route(
) -> Result<Resp, Error> {
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)
}
Expand Down Expand Up @@ -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<Frame<Bytes>, Infallible>,
>()))
}

fn full_body(bytes: Vec<u8>) -> RespBody {
Full::new(Bytes::from(bytes))
.map_err(|never| match never {})
Expand Down Expand Up @@ -1300,6 +1325,7 @@ pub async fn infallible_route(
network: Network,
add_cors: bool,
) -> Result<Resp, hyper::Error> {
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),
Expand All @@ -1311,14 +1337,18 @@ 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,
"Content-Type".parse().unwrap(),
);
}

if is_head {
response = strip_head_body(response);
}

Ok(response)
}

Expand Down
30 changes: 30 additions & 0 deletions tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<usize>()
.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;
Expand All @@ -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);
Expand Down
Loading