Skip to content
Open
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 crates/core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -718,6 +718,11 @@ pub struct MinibfConfig {
pub url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
max_scan_items: Option<u64>,
/// 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<String>,
}

impl MinibfConfig {
Expand All @@ -728,6 +733,7 @@ impl MinibfConfig {
token_registry_url: None,
url: None,
max_scan_items: None,
base_path: None,
}
}

Expand Down
3 changes: 3 additions & 0 deletions crates/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),

Expand Down
12 changes: 12 additions & 0 deletions crates/minibf/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
73 changes: 69 additions & 4 deletions crates/minibf/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ impl<D: Domain> Facade<D> {

pub struct Driver;

pub fn build_router<D>(cfg: MinibfConfig, domain: D) -> Router
pub fn build_router<D>(cfg: MinibfConfig, domain: D) -> Result<Router, ServeError>
where
D: Domain + SubmitExt + Clone + Send + Sync + 'static,
Option<AccountState>: From<D::Entity>,
Expand All @@ -285,7 +285,7 @@ where
})
}

pub(crate) fn build_router_with_facade<D>(facade: Facade<D>) -> Router
pub(crate) fn build_router_with_facade<D>(facade: Facade<D>) -> Result<Router, ServeError>
where
D: Domain + SubmitExt + Clone + Send + Sync + 'static,
Option<AccountState>: From<D::Entity>,
Expand All @@ -295,6 +295,7 @@ where
Option<DRepState>: From<D::Entity>,
{
let permissive_cors = facade.config.permissive_cors();
let base_path = facade.config.base_path.clone();
let app = Router::new()
.route("/", get(routes::root::<D>))
.route("/health", get(routes::health::naked))
Expand Down Expand Up @@ -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()))
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

impl<D: Domain + SubmitExt, C: CancelToken> dolos_core::Driver<D, C> for Driver
Expand All @@ -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
Expand All @@ -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:?}"
);
}
}
}
35 changes: 29 additions & 6 deletions crates/minibf/src/test_support.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,28 +108,51 @@ impl TestApp {
}

pub fn new_with_cfg_and_fault(cfg: SyntheticBlockConfig, fault: Option<TestFault>) -> 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<String>,
) -> Result<Self, dolos_core::ServeError> {
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<TestFault>,
minibf: MinibfConfig,
) -> Result<Self, dolos_core::ServeError> {
let (domain, vectors) = TestDomainBuilder::new_with_synthetic(cfg).finish();

let domain = match fault {
Some(fault) => dolos_testing::faults::FaultyToyDomain::new(domain, fault),
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<u8>) {
Expand Down
19 changes: 11 additions & 8 deletions docs/content/apis/minibf.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion src/bin/dolos/minibf.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down