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
3 changes: 3 additions & 0 deletions api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ license = "Apache-2.0"
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
tower = { version = "0.4", features = ["util"] }
tower-http = { version = "0.5", features = ["cors", "trace"] }
tower_governor = "0.5"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
Expand All @@ -21,6 +22,8 @@ tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
url = "2"
metrics = "0.23"
metrics-exporter-prometheus = { version = "0.15", default-features = false }

[[bin]]
name = "stellar-rwa-api"
Expand Down
41 changes: 41 additions & 0 deletions api/src/indexer/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ use std::sync::Arc;
use std::time::Duration;

use arc_swap::ArcSwap;
use metrics_exporter_prometheus::PrometheusHandle;
use reqwest::header::RETRY_AFTER;
use reqwest::StatusCode;
use serde::Deserialize;
Expand Down Expand Up @@ -137,9 +138,49 @@ impl AppState {
(*guard).clone()
}

/// The ledger number the indexer last successfully read from.
///
/// Used by the cache-headers middleware to derive a stable ETag for a
/// snapshot: two requests against the same indexed ledger are guaranteed
/// to produce identical response bodies.
pub async fn last_indexed_ledger(&self) -> u32 {
self.snapshot().stats.last_indexed_ledger
}

fn replace(&self, next: Snapshot) {
self.inner.store(Arc::new(next));
}

/// Construct an `AppState` backed by an empty [`Snapshot`] for use in
/// unit tests. Avoids the need to supply a real config or Prometheus
/// handle; the empty snapshot contains no assets, so any asset look-up
/// will return `None`.
#[cfg(test)]
pub fn for_test() -> Self {
use metrics_exporter_prometheus::PrometheusBuilder;
let config = Config::from_env().expect("default config values are valid");
let metrics = PrometheusBuilder::new()
.build_recorder()
.handle();
AppState {
inner: ArcSwap::from_arc(Arc::new(Snapshot::default())),
config: Arc::new(config),
metrics,
}
}

/// Construct an `AppState` pre-seeded with the provided assets for use in
/// unit tests. All other snapshot collections (holders, compliance,
/// dividends) remain empty.
#[cfg(test)]
pub fn with_assets(assets: Vec<Asset>) -> Self {
let state = Self::for_test();
state.replace(Snapshot {
assets,
..Snapshot::default()
});
state
}
}

#[derive(Debug, thiserror::Error)]
Expand Down
1 change: 1 addition & 0 deletions api/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ mod routes;
use std::net::SocketAddr;

use indexer::{AppState, Config, ConfigError, Indexer};
use metrics_exporter_prometheus::PrometheusBuilder;

#[tokio::main]
async fn main() {
Expand Down
4 changes: 2 additions & 2 deletions api/src/models/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use serde::Serialize;

/// A tokenized real-world asset, joined from the registry entry and the token
/// contract metadata.
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, serde::Deserialize)]
pub struct Asset {
pub id: u64,
pub token_contract: String,
Expand Down Expand Up @@ -100,7 +100,7 @@ pub struct Stats {
}

/// Standard error body returned by the API.
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, serde::Deserialize)]
pub struct ApiErrorBody {
pub error: String,
pub message: String,
Expand Down
153 changes: 153 additions & 0 deletions api/src/routes/assets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,156 @@ pub async fn detail(
.map(Json)
.ok_or_else(|| ApiError::NotFound(format!("no asset with id {id}")))
}

#[cfg(test)]
mod tests {
use axum::{
body::Body,
http::{Request, StatusCode},
routing::get,
Router,
};
use tower::ServiceExt as _;

use crate::indexer::AppState;
use crate::models::Asset;

// -----------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------

/// Return a minimal but fully-populated [`Asset`] with controllable
/// `asset_type` and `active` fields. All other fields are filled with
/// dummy values that do not affect the filter logic.
fn stub_asset(id: u64, asset_type: &str, active: bool) -> Asset {
Asset {
id,
token_contract: format!("CONTRACT{id}"),
issuer: format!("ISSUER{id}"),
name: format!("Asset {id}"),
symbol: format!("TKN{id}"),
asset_type: asset_type.to_string(),
description: String::new(),
valuation_cents: "0".to_string(),
valuation_usd: 0.0,
decimals: 7,
total_supply: "0".to_string(),
holders: 0,
active,
paused: false,
compliance_contract: format!("COMPLIANCE{id}"),
created_at_ledger: 1,
}
}

/// Build a router for `GET /assets` seeded with the supplied assets.
fn list_router(assets: Vec<Asset>) -> Router {
let state = AppState::with_assets(assets);
Router::new()
.route("/assets", get(super::list))
.with_state(state)
}

/// Send `GET {uri}` through `app`, assert the status, and decode the JSON
/// body as a `Vec<Asset>`. Panics on any parse / transport error so test
/// failures surface a clear message rather than an `unwrap` backtrace.
async fn get_assets(app: Router, uri: &str) -> Vec<Asset> {
let resp = app
.oneshot(
Request::builder()
.uri(uri)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();

assert_eq!(
resp.status(),
StatusCode::OK,
"expected 200 from {uri}, got {}",
resp.status()
);

let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
serde_json::from_slice(&bytes)
.unwrap_or_else(|e| panic!("failed to parse asset list JSON from {uri}: {e}"))
}

// -----------------------------------------------------------------------
// Filter tests
// -----------------------------------------------------------------------

/// `?asset_type=real_estate` must return only the assets whose
/// `asset_type` field equals `"real_estate"`, regardless of `active`.
#[tokio::test]
async fn filter_by_asset_type_returns_matching_assets() {
let assets = vec![
stub_asset(1, "real_estate", true),
stub_asset(2, "real_estate", false),
stub_asset(3, "bond", true),
stub_asset(4, "commodity", false),
];
let app = list_router(assets);

let result = get_assets(app, "/assets?asset_type=real_estate").await;

assert_eq!(result.len(), 2, "expected 2 real_estate assets, got {}", result.len());
assert!(
result.iter().all(|a| a.asset_type == "real_estate"),
"all returned assets must be real_estate; got: {:?}",
result.iter().map(|a| &a.asset_type).collect::<Vec<_>>(),
);
}

/// `?active=true` must return only assets where `active == true`,
/// regardless of `asset_type`.
#[tokio::test]
async fn filter_by_active_returns_only_active_assets() {
let assets = vec![
stub_asset(1, "real_estate", true),
stub_asset(2, "bond", true),
stub_asset(3, "real_estate", false),
stub_asset(4, "commodity", false),
];
let app = list_router(assets);

let result = get_assets(app, "/assets?active=true").await;

assert_eq!(result.len(), 2, "expected 2 active assets, got {}", result.len());
assert!(
result.iter().all(|a| a.active),
"all returned assets must be active; got ids: {:?}",
result.iter().map(|a| a.id).collect::<Vec<_>>(),
);
}

/// `?asset_type=real_estate&active=true` must return only assets that
/// satisfy *both* predicates simultaneously.
#[tokio::test]
async fn filter_by_asset_type_and_active_combined() {
let assets = vec![
stub_asset(1, "real_estate", true), // ✓ matches both
stub_asset(2, "real_estate", false), // ✗ wrong active
stub_asset(3, "bond", true), // ✗ wrong type
stub_asset(4, "commodity", false), // ✗ wrong both
];
let app = list_router(assets);

let result =
get_assets(app, "/assets?asset_type=real_estate&active=true").await;

assert_eq!(
result.len(),
1,
"expected exactly 1 asset matching both filters, got {}: {:?}",
result.len(),
result.iter().map(|a| a.id).collect::<Vec<_>>(),
);
assert_eq!(result[0].id, 1);
assert_eq!(result[0].asset_type, "real_estate");
assert!(result[0].active);
}
}