diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 8283d381..a2a1697c 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -718,6 +718,11 @@ pub struct MinibfConfig { pub url: Option, #[serde(default, skip_serializing_if = "Option::is_none")] max_scan_items: Option, + /// Optional base path for all Blockfrost API endpoints (e.g., "/api/v0"). + /// When set, all API routes will be nested under this path. + /// Set to "/api/v0" for full Blockfrost OpenAPI specification compliance. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_path: Option, } impl MinibfConfig { @@ -728,6 +733,7 @@ impl MinibfConfig { token_registry_url: None, url: None, max_scan_items: None, + base_path: None, } } diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 0b8eae59..a4f4858c 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -326,6 +326,9 @@ impl WalError { #[derive(Debug, Error)] pub enum ServeError { + #[error("invalid configuration: {0}")] + ConfigError(String), + #[error("failed to bind listener")] BindError(std::io::Error), diff --git a/crates/minibf/README.md b/crates/minibf/README.md index 21b32d2b..d24b254c 100644 --- a/crates/minibf/README.md +++ b/crates/minibf/README.md @@ -6,6 +6,18 @@ Blockfrost-compatible HTTP API service for the Dolos Cardano data node. `dolos-minibf` provides a REST API that mimics the Blockfrost API, allowing existing Cardano ecosystem tools to work seamlessly with Dolos without requiring code changes. It serves as an API compatibility layer for existing Blockfrost clients. +### Blockfrost Spec Compliance + +The official [Blockfrost OpenAPI specification](https://github.com/blockfrost/openapi) requires all endpoints to be served under the `/api/v0` base path. Dolos supports this via the `base_path` configuration option: + +```toml +[serve.minibf] +listen_address = "[::]:3000" +base_path = "/api/v0" # For full Blockfrost spec compliance +``` + +When `base_path` is set, **all routes** (including health and metrics) are served under that prefix (e.g., `/api/v0/blocks/latest`, `/api/v0/health`, `/api/v0/metrics`). If omitted, endpoints are served at the root for backward compatibility. + ## Features ### API Compatibility diff --git a/crates/minibf/src/lib.rs b/crates/minibf/src/lib.rs index 2c1c4b06..4c5c04ae 100644 --- a/crates/minibf/src/lib.rs +++ b/crates/minibf/src/lib.rs @@ -269,7 +269,7 @@ impl Facade { pub struct Driver; -pub fn build_router(cfg: MinibfConfig, domain: D) -> Router +pub fn build_router(cfg: MinibfConfig, domain: D) -> Result where D: Domain + SubmitExt + Clone + Send + Sync + 'static, Option: From, @@ -285,7 +285,7 @@ where }) } -pub(crate) fn build_router_with_facade(facade: Facade) -> Router +pub(crate) fn build_router_with_facade(facade: Facade) -> Result where D: Domain + SubmitExt + Clone + Send + Sync + 'static, Option: From, @@ -295,6 +295,7 @@ where Option: From, { let permissive_cors = facade.config.permissive_cors(); + let base_path = facade.config.base_path.clone(); let app = Router::new() .route("/", get(routes::root::)) .route("/health", get(routes::health::naked)) @@ -507,7 +508,25 @@ where } else { CorsLayer::new() }); - app.layer(NormalizePathLayer::trim_trailing_slash()) + + if let Some(base_path) = &base_path { + let base_path = base_path.trim_end_matches('/'); + if base_path.is_empty() + || base_path == "/" + || !base_path.starts_with('/') + || base_path.contains('*') + { + return Err(ServeError::ConfigError(format!( + "base_path must start with '/', must not be just '/', and must not contain wildcards; got: \"{}\"", + base_path + ))); + } + Ok(Router::new() + .nest(base_path, app) + .layer(NormalizePathLayer::trim_trailing_slash())) + } else { + Ok(app.layer(NormalizePathLayer::trim_trailing_slash())) + } } impl dolos_core::Driver for Driver @@ -522,7 +541,7 @@ where type Config = MinibfConfig; async fn run(cfg: Self::Config, domain: D, cancel: C) -> Result<(), ServeError> { - let app = build_router(cfg.clone(), domain); + let app = build_router(cfg.clone(), domain)?; let listener = tokio::net::TcpListener::bind(cfg.listen_address) .await @@ -536,3 +555,49 @@ where Ok(()) } } + +#[cfg(test)] +mod base_path_tests { + use axum::http::StatusCode; + use dolos_core::ServeError; + + use crate::test_support::TestApp; + + #[tokio::test] + async fn routes_resolve_under_configured_base_path() { + let app = TestApp::try_new_with_base_path(Some("/api/v0".into())) + .expect("router should build with valid base_path"); + + let (status, _) = app.get_bytes("/api/v0/network").await; + assert_eq!(status, StatusCode::OK, "prefixed route should resolve"); + + let (status, _) = app.get_bytes("/network").await; + assert_eq!( + status, + StatusCode::NOT_FOUND, + "root route should 404 when base_path is set" + ); + } + + #[tokio::test] + async fn trailing_slash_in_base_path_is_normalized() { + let app = TestApp::try_new_with_base_path(Some("/api/v0/".into())) + .expect("router should build with trailing-slash base_path"); + + let (status, _) = app.get_bytes("/api/v0/network").await; + assert_eq!(status, StatusCode::OK); + } + + #[tokio::test] + async fn invalid_base_path_returns_config_error() { + for invalid in ["", "/", "no-leading-slash", "/with*wildcard"] { + let err = TestApp::try_new_with_base_path(Some(invalid.into())) + .err() + .unwrap_or_else(|| panic!("expected ConfigError for base_path = {invalid:?}")); + assert!( + matches!(err, ServeError::ConfigError(_)), + "expected ServeError::ConfigError for {invalid:?}, got {err:?}" + ); + } + } +} diff --git a/crates/minibf/src/test_support.rs b/crates/minibf/src/test_support.rs index b579e693..71dc7b52 100644 --- a/crates/minibf/src/test_support.rs +++ b/crates/minibf/src/test_support.rs @@ -108,6 +108,31 @@ impl TestApp { } pub fn new_with_cfg_and_fault(cfg: SyntheticBlockConfig, fault: Option) -> Self { + let minibf = MinibfConfig::new("[::]:0".parse().expect("invalid listen address")); + Self::build(cfg, fault, minibf).expect("build_router_with_facade") + } + + pub fn try_new_with_base_path( + base_path: Option, + ) -> Result { + let mut minibf = MinibfConfig::new("[::]:0".parse().expect("invalid listen address")); + minibf.base_path = base_path; + Self::build( + SyntheticBlockConfig { + block_count: 5, + txs_per_block: 3, + ..Default::default() + }, + None, + minibf, + ) + } + + fn build( + cfg: SyntheticBlockConfig, + fault: Option, + minibf: MinibfConfig, + ) -> Result { let (domain, vectors) = TestDomainBuilder::new_with_synthetic(cfg).finish(); let domain = match fault { @@ -115,21 +140,19 @@ impl TestApp { None => dolos_testing::faults::FaultyToyDomain::new(domain, TestFault::None), }; - let cfg = MinibfConfig::new("[::]:0".parse().expect("invalid listen address")); - let facade = Facade { inner: domain.clone(), - config: cfg, + config: minibf, cache: crate::cache::CacheService::default(), }; - let router = build_router_with_facade(facade); + let router = build_router_with_facade(facade)?; - Self { + Ok(Self { router, _domain: domain, vectors, - } + }) } pub async fn get_bytes(&self, path: &str) -> (StatusCode, Vec) { diff --git a/docs/content/apis/minibf.mdx b/docs/content/apis/minibf.mdx index 2fbdfa4e..b4b42665 100644 --- a/docs/content/apis/minibf.mdx +++ b/docs/content/apis/minibf.mdx @@ -66,19 +66,21 @@ The endpoints required for most tx builders to work are supported. Libraries lik The `serve.minibf` section controls the options for the MiniBF endpoint that can be used by clients. -| property | type | example | -| ------------------ | ------- | -------------------------------- | -| listen_address | string | "[::]:3000" | -| permissive_cors | boolean | true | -| token_registry_url | string | "https://token-registry.io" | -| url | string | "https://minibf.local" | -| max_scan_items | integer | 3000 | +| property | type | example | description | +| ------------------ | ------- | -------------------------------- | ----------------------------------------------------------------- | +| listen_address | string | "[::]:3000" | Local address to listen for incoming connections | +| permissive_cors | boolean | true | Allow cross-origin requests from any origin | +| token_registry_url | string | "https://token-registry.io" | Optional token registry base URL for off-chain asset metadata | +| url | string | "https://minibf.local" | Optional public URL used in the `/` root response | +| max_scan_items | integer | 3000 | Caps page-based scans for heavy endpoints (defaults to 3000) | +| base_path | string | "/api/v0" | Optional base path for API endpoints (Blockfrost spec compliance) | - `listen_address`: the local address (`IP:PORT`) to listen for incoming connections (`[::]` represents any IP address). - `permissive_cors`: allow cross-origin requests from any origin. -- `token_registry_url`: optional token registry base URL used for off-chain asset metadata. +- `token_registry_url`: optional token registry base URL used for off-chain asset metadata. - `url`: optional public URL used in the `/` root response. - `max_scan_items`: caps page-based scans for heavy endpoints (defaults to 3000 if unset). +- `base_path`: optional base path prefix for all endpoints. Set to `"/api/v0"` for full Blockfrost OpenAPI specification compliance. When configured, **all routes** (including health and metrics) will be available under this prefix (e.g., `/api/v0/blocks/latest`, `/api/v0/health`, `/api/v0/metrics`). If omitted, endpoints are served at the root (default behavior for backward compatibility). This is an example of the `serve.minibf` fragment with a `dolos.toml` configuration file. @@ -89,6 +91,7 @@ permissive_cors = true token_registry_url = "https://token-registry.io" url = "https://minibf.local" max_scan_items = 3000 +# base_path = "/api/v0" # Uncomment for Blockfrost OpenAPI spec compliance ``` Check the [Configuration Schema](../configuration/schema) for detailed info on how to set this up. diff --git a/src/bin/dolos/minibf.rs b/src/bin/dolos/minibf.rs index 62f32ebf..4c0278bd 100644 --- a/src/bin/dolos/minibf.rs +++ b/src/bin/dolos/minibf.rs @@ -34,7 +34,9 @@ pub async fn run(config: &RootConfig, args: &Args) -> miette::Result<()> { .into_diagnostic() .context("invalid minibf path")?; - let app = dolos_minibf::build_router(minibf.clone(), domain); + let app = dolos_minibf::build_router(minibf.clone(), domain) + .into_diagnostic() + .context("building minibf router")?; let request = Request::builder() .method("GET")