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 api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ metrics = "0.24"
metrics-exporter-prometheus = "0.16"
rand = "0.9"
url = "2"
metrics = "0.24"
metrics-exporter-prometheus = { version = "0.18", default-features = false }

[dev-dependencies]
tower = { version = "0.4", features = ["util"] }
http-body-util = "0.1"

[dev-dependencies]
insta = { version = "1", features = ["json"] }
Expand Down
6 changes: 6 additions & 0 deletions api/src/indexer/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,12 @@ impl AppState {
self.inner.store(Arc::new(next));
}

/// Seed the shared snapshot directly, bypassing the indexer. Route tests
/// use this to exercise "asset known, nested resource unknown" branches
/// without a live Soroban RPC.
#[cfg(test)]
pub(crate) fn seed_for_test(&self, snapshot: Snapshot) {
self.replace(snapshot);
/// Test-only: build state pre-populated with `snapshot`.
#[cfg(test)]
pub(crate) fn for_test(config: Config, metrics: PrometheusHandle, snapshot: Snapshot) -> Self {
Expand Down
2 changes: 2 additions & 0 deletions api/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ mod routes;

use std::net::SocketAddr;

use indexer::{AppState, Config, ConfigError, Indexer};
use metrics_exporter_prometheus::PrometheusBuilder;
use indexer::{AppState, Config, Indexer};
use metrics_exporter_prometheus::PrometheusBuilder;
use tokio::sync::watch;
Expand Down
108 changes: 107 additions & 1 deletion api/src/routes/dividends.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//! `GET /assets/:id/dividends`.
//! `GET /assets/:id/dividends` and `GET /assets/:id/dividends/:did`.

use axum::{
extract::{Path, State},
Expand All @@ -23,6 +23,7 @@ pub async fn list(
Ok(Json(dists))
}

/// A single distribution by id within an asset's dividend history.
/// `GET /assets/:id/distributions/:did` — a single distribution by id.
pub async fn get_one(
State(state): State<AppState>,
Expand All @@ -32,6 +33,12 @@ pub async fn get_one(
if snap.asset(id).is_none() {
return Err(ApiError::NotFound(format!("no asset with id {id}")));
}
snap.dividends
.get(&id)
.and_then(|dists| dists.iter().find(|d| d.id == did))
.cloned()
.map(Json)
.ok_or_else(|| ApiError::NotFound(format!("no distribution {did} for asset {id}")))
let dist = snap
.dividends
.get(&id)
Expand All @@ -45,6 +52,105 @@ pub async fn get_one(

#[cfg(test)]
mod tests {
use axum::body::Body;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
use tower::ServiceExt;

use crate::routes::{router, test_support::test_state};

/// Mirrors the existing coverage for a non-numeric `:id` on `GET /assets/:id`
/// (400, rejected by axum's path extractor before the handler runs) but for
/// the distribution id segment of `GET /assets/:id/dividends/:did`.
#[tokio::test]
async fn non_numeric_distribution_id_is_rejected_with_400() {
let app = router(test_state());
let resp = app
.oneshot(
Request::builder()
.uri("/assets/1/dividends/abc")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn get_one_404s_for_unknown_asset() {
let app = router(test_state());
let resp = app
.oneshot(
Request::builder()
.uri("/assets/999/dividends/1")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn get_one_404s_for_unknown_distribution() {
let app = router(crate::routes::test_support::test_state_with_asset(1));
let resp = app
.oneshot(
Request::builder()
.uri("/assets/1/dividends/999")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["error"], "not_found");
}

/// Distribution ids come from on-chain state, so a malicious or malformed
/// id must 404 cleanly rather than panic or wrap around — cover both ends
/// of the u64 range, mirroring the asset-id boundary coverage (#194/#210).
#[tokio::test]
async fn get_one_404s_cleanly_at_distribution_id_boundaries() {
let app = router(crate::routes::test_support::test_state_with_asset(1));
for did in [0u64, u64::MAX] {
let resp = app
.clone()
.oneshot(
Request::builder()
.uri(format!("/assets/1/dividends/{did}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND, "did={did}");
let body = resp.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["error"], "not_found", "did={did}");
}
}

/// Same boundary values, but against an asset that doesn't exist either —
/// the asset-not-found branch must still win cleanly, not panic.
#[tokio::test]
async fn get_one_404s_cleanly_at_distribution_id_boundaries_unknown_asset() {
let app = router(test_state());
for did in [0u64, u64::MAX] {
let resp = app
.clone()
.oneshot(
Request::builder()
.uri(format!("/assets/999/dividends/{did}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND, "did={did}");
use axum::extract::{Path, State};

use super::{get_one, list};
Expand Down
154 changes: 154 additions & 0 deletions api/src/routes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,23 @@ fn env_value<T: std::str::FromStr>(name: &str, default: T) -> T {
.unwrap_or(default)
}

/// Snapshot-backed data routes, mounted under [`API_VERSION_PREFIX`]. Single
/// source of truth for the router, the root index, and the
/// docs/public/openapi.json-vs-router test — keep in sync with the
/// `.route(...)` calls in [`router`] and with the spec.
const DATA_ROUTE_PATHS: &[&str] = &[
"/stats",
"/assets",
"/assets/:id",
"/assets/:id/holders",
"/assets/:id/compliance",
"/assets/:id/dividends",
"/assets/:id/dividends/:did",
];

/// Path prefix all data routes are nested under.
const API_VERSION_PREFIX: &str = "/v1";

/// Errors surfaced to API clients as a JSON body with an appropriate status.
#[derive(Debug)]
pub enum ApiError {
Expand Down Expand Up @@ -102,6 +119,11 @@ pub fn router(state: AppState) -> Router {
.route("/assets/:id/holders", get(holders::list))
.route("/assets/:id/compliance", get(compliance::summary))
.route("/assets/:id/dividends", get(dividends::list))
.route("/assets/:id/dividends/:did", get(dividends::get_one))
.layer(middleware::from_fn_with_state(state.clone(), cache_headers))
.layer(GovernorLayer {
config: governor_conf,
});
.route("/assets/:id/distributions/:did", get(dividends::get_one))
.layer(middleware::from_fn_with_state(state.clone(), cache_headers));

Expand All @@ -110,6 +132,7 @@ pub fn router(state: AppState) -> Router {
.route("/version", get(version))
.route("/health", get(health))
.route("/metrics", get(metrics))
.nest(API_VERSION_PREFIX, data_routes)
.nest("/v1", data_routes)
.with_state(state)
.layer(TimeoutLayer::new(Duration::from_secs(env_value(
Expand Down Expand Up @@ -168,10 +191,16 @@ fn insert_cache_headers(headers: &mut HeaderMap, etag: &str) {

/// Root — a small self-describing index of the available endpoints.
async fn index() -> Json<serde_json::Value> {
let endpoints: Vec<String> = DATA_ROUTE_PATHS
.iter()
.map(|path| format!("GET {API_VERSION_PREFIX}{path}"))
.chain(["GET /health".to_string(), "GET /metrics".to_string()])
.collect();
Json(json!({
"name": "Stellar RWA API",
"version": env!("CARGO_PKG_VERSION"),
"description": "Read-only index of tokenized real-world asset activity on Stellar.",
"endpoints": endpoints,
"endpoints": [
"GET /version",
"GET /v1/stats",
Expand Down Expand Up @@ -252,3 +281,128 @@ async fn metrics(headers: HeaderMap, State(state): State<AppState>) -> Response
)
.into_response()
}

/// Shared helpers for building an [`AppState`] in router-level tests, without
/// a live Soroban RPC or the process-global Prometheus recorder.
#[cfg(test)]
pub(crate) mod test_support {
use metrics_exporter_prometheus::PrometheusBuilder;

use crate::indexer::{AppState, Config, Snapshot};
use crate::models::Asset;

fn test_config() -> Config {
Config {
rpc_url: "https://example.invalid".to_string(),
registry_id: "CBX5SMLTXX6JP4HA5GQIO2V6QM7WCUGL2GZ6D4U773HMRI6RXISKPUR3".to_string(),
dividend_id: "CAR4XY3CEBQWFOL27JEWFW34KXSIZA7RFKDQMEIV7ZU723RWY37I2SYX".to_string(),
read_source: "GAIQGTOBTTLLDJ4SWGGESM7UWJ2DI4K3ZNHUSHPDKJL2IE5FKY3BSRAA".to_string(),
}
}

/// An `AppState` with an empty snapshot — no assets, no data.
pub(crate) fn test_state() -> AppState {
// `build_recorder` (as opposed to `install_recorder`) does not touch
// the process-global recorder, so it's safe to call from many tests.
let handle = PrometheusBuilder::new().build_recorder().handle();
AppState::new(test_config(), handle)
}

/// An `AppState` seeded with a single bare-minimum asset at `id`, and no
/// holders/compliance/dividends data.
pub(crate) fn test_state_with_asset(id: u64) -> AppState {
let state = test_state();
state.seed_for_test(Snapshot {
assets: vec![Asset {
id,
token_contract: "CBMCWLSQSWUTLUJFCNBHNBSXMUM3XU7NAQ5TSNERW4HA4ZZBYHLG4ECZ"
.to_string(),
issuer: "GAIQGTOBTTLLDJ4SWGGESM7UWJ2DI4K3ZNHUSHPDKJL2IE5FKY3BSRAA".to_string(),
name: "Test Asset".to_string(),
symbol: "TEST".to_string(),
asset_type: "real_estate".to_string(),
description: String::new(),
valuation_cents: "100".to_string(),
valuation_usd: 1.0,
decimals: 2,
total_supply: "100".to_string(),
holders: 0,
active: true,
paused: false,
compliance_contract: "CBUERYDM7DXTZLLKDBRJKUBPFJ7M4OSUN4T7XKUARU345RLXNAIQD2IU"
.to_string(),
created_at_ledger: 1,
}],
..Snapshot::default()
});
state
}
}

#[cfg(test)]
mod tests {
use std::collections::BTreeSet;

use axum::body::Body;
use axum::http::Request;
use http_body_util::BodyExt;
use tower::ServiceExt;

use super::*;

/// docs/public/openapi.json must document exactly the paths the router
/// mounts under /v1 (see #262: a spec/router mismatch 404s anyone who
/// follows the docs or generates a client from the spec), and every
/// documented path must actually resolve on the live router.
#[tokio::test]
async fn openapi_paths_match_router() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let spec_path = format!("{manifest_dir}/../docs/public/openapi.json");
let raw = std::fs::read_to_string(&spec_path)
.unwrap_or_else(|e| panic!("failed to read {spec_path}: {e}"));
let spec: serde_json::Value =
serde_json::from_str(&raw).expect("docs/public/openapi.json should be valid JSON");

let spec_paths: BTreeSet<String> = spec["paths"]
.as_object()
.expect("openapi.json should have a top-level \"paths\" object")
.keys()
.cloned()
.collect();

let router_paths: BTreeSet<String> = DATA_ROUTE_PATHS
.iter()
.map(|path| {
format!(
"{API_VERSION_PREFIX}{}",
path.replace(":id", "{id}").replace(":did", "{did}")
)
})
.collect();

assert_eq!(
spec_paths, router_paths,
"docs/public/openapi.json \"paths\" keys must match the router's mounted /v1 routes"
);

let app = router(test_support::test_state());
for path in &router_paths {
let concrete = path.replace("{id}", "1").replace("{did}", "1");
let resp = app
.clone()
.oneshot(
Request::builder()
.uri(concrete.clone())
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = resp.into_body().collect().await.unwrap().to_bytes();
assert!(
serde_json::from_slice::<serde_json::Value>(&body).is_ok(),
"documented path {path} (requested as {concrete}) has no live route on the router"
);
}
}
}
Loading
Loading