Rust Axum REST API: Auth, CORS, Rate Limiting & OpenAPI
Building a production-ready API with middleware, role-based authentication, and auto-generated docs

Building a Production REST API with Axum: Authentication, CORS, Rate Limiting, and OpenAPI
We built a complete REST API for a Bitcoin-style blockchain node using Axum, Rust's async web framework built on Tokio and Tower. The API handles wallet management, transaction submission, block queries, mining operations, and health monitoring — all with role-based authentication, CORS, rate limiting, and auto-generated OpenAPI documentation.
This post walks through the architecture and implementation patterns we used, with real code from the project. If you're building a REST API in Rust with Axum, these patterns transfer directly to any domain — not just blockchain.
What We're Building
The API serves as the primary interface to a blockchain node. Desktop clients (we built two — one in Iced, one in Tauri 2), web browsers, and programmatic clients all interact with the node through this single HTTP layer.
Here's the endpoint surface:
| Group | Endpoints | Auth Required |
|---|---|---|
| Blockchain | GET /api/v1/blockchain, /blocks, /blocks/latest, /blocks/{hash} |
Admin |
| Wallet | POST /wallet, GET /addresses, /{address}, /{address}/balance |
Admin or Wallet |
| Transactions | POST /transactions, GET /transactions, /mempool, /address/{address} |
Admin or Wallet |
| Mining | GET /mining/info, POST /mining/generatetoaddress |
Admin |
| Health | GET /health, /health/live, /health/ready |
None |
| Docs | GET /swagger-ui |
None |
Every request passes through a middleware pipeline before reaching a handler. Let's start with the architecture.
Architecture Overview
The API follows a layered architecture where each component has a single responsibility. Routes map URLs to handlers. Handlers contain business logic. Middleware handles cross-cutting concerns. Models define request and response shapes. The server orchestrates everything.
bitcoin/src/web/
├── server.rs # Server initialization + middleware stack
├── routes/
│ ├── api.rs # API route definitions
│ └── web.rs # Swagger UI + static files
├── handlers/
│ ├── blockchain.rs # Block and chain queries
│ ├── wallet.rs # Wallet creation + balance
│ ├── transaction.rs # Transaction submission + history
│ ├── mining.rs # Block generation
│ └── health.rs # Liveness + readiness probes
├── middleware/
│ ├── auth.rs # API key authentication
│ ├── cors.rs # CORS configuration
│ ├── logging.rs # Structured logging
│ └── rate_limit.rs # Token bucket rate limiting
├── models/
│ ├── requests.rs # Typed request bodies
│ ├── responses.rs # Typed response envelopes
│ └── errors.rs # Error types + HTTP mapping
└── openapi.rs # Utoipa OpenAPI spec
This structure is intentionally flat. Every handler file maps to one domain. Every middleware file handles one concern. You can find any piece of code in seconds.
The Request Pipeline
Every HTTP request flows through the same sequence of middleware layers before reaching a handler. Understanding this pipeline is essential for debugging and extending the API.
The order matters. CORS runs first because browsers send preflight OPTIONS requests that should be handled before any authentication check. Error handling wraps everything inside it so that even middleware failures produce clean JSON responses. Authentication runs per-route (not globally) because health checks are public.
Server Setup
The server is configured through a WebServer struct that holds configuration and a shared reference to the blockchain node:
pub struct WebServer {
config: WebServerConfig,
node: Arc<NodeContext>,
}
#[derive(Debug, Clone)]
pub struct WebServerConfig {
pub host: String,
pub port: u16,
pub enable_cors: bool,
pub enable_rate_limiting: bool,
pub rate_limit_requests_per_second: u32,
pub rate_limit_burst_size: u32,
}
The Arc<NodeContext> is the key design decision. Every handler receives a reference-counted pointer to the same blockchain node. There's no global state, no mutex contention on the hot path, and the type system enforces that handlers can only read blockchain data through the NodeContext interface.
Building the application router combines routes and middleware in a single create_app() method:
pub fn create_app(
&self,
) -> Result<Router, Box<dyn std::error::Error + Send + Sync>> {
let app = Router::new()
.merge(create_all_api_routes())
.merge(create_wallet_only_routes())
.merge(create_web_routes())
.with_state(self.node.clone());
let mut app = app;
// Rate limiting (conditional)
if self.config.enable_rate_limiting {
let rl_config = RateLimitConfig::default();
if let Some(manager) = build_rate_limiter_manager(&rl_config)? {
app = app.layer(from_fn_with_state(
manager,
axum_rate_limiter::limiter::middleware,
));
}
}
// CORS (conditional)
if self.config.enable_cors {
app = app.layer(cors::create_cors_layer());
}
// Compression + error handling (always on)
app = app.layer(CompressionLayer::new());
app = app.layer(axum::middleware::from_fn(handle_errors));
Ok(app)
}
Three route sets are merged into one router: the main API endpoints, wallet-specific endpoints with their own auth, and web routes (Swagger UI). State is injected once with .with_state(), then middleware layers are applied conditionally based on configuration.
The server starts with graceful shutdown support — in-flight requests complete before the process exits:
pub async fn start_with_shutdown(
&self,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let app = self.create_app()?;
let addr = SocketAddr::from(([0, 0, 0, 0], self.config.port));
let listener = tokio::net::TcpListener::bind(addr).await?;
let shutdown_signal = async {
tokio::signal::ctrl_c()
.await
.expect("Failed to install CTRL+C signal handler");
tracing::info!("Shutdown signal received");
};
axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.with_graceful_shutdown(shutdown_signal)
.await?;
Ok(())
}
The into_make_service_with_connect_info::<SocketAddr>() call preserves client IP addresses through the service layer — essential for per-IP rate limiting.
Route Organization
Routes are organized into four groups, each with appropriate authentication:
// Public API routes — full endpoint set, requires admin auth
pub fn create_api_routes() -> Router<Arc<NodeContext>> {
Router::new()
.route("/blockchain", get(blockchain::get_blockchain_info))
.route("/blockchain/blocks", get(blockchain::get_blocks))
.route("/blockchain/blocks/latest", get(blockchain::get_latest_blocks))
.route("/blockchain/blocks/{hash}", get(blockchain::get_block_by_hash))
.route("/wallet", post(wallet::create_wallet))
.route("/wallet/{address}/balance", get(wallet::get_balance))
.route("/transactions", post(transaction::send_transaction))
.route("/transactions/mempool", get(transaction::get_mempool))
.route("/mining/info", get(mining::get_mining_info))
.route("/mining/generatetoaddress", post(mining::generate_to_address))
}
// Admin routes — nests API routes under /api/admin with admin auth
pub fn create_admin_api_routes() -> Router<Arc<NodeContext>> {
Router::new()
.nest("/api/admin", create_api_routes())
.nest("/api/admin", create_monitor_api_routes())
.layer(axum::middleware::from_fn(require_admin))
}
// Wallet routes — limited subset with wallet-level auth
pub fn create_wallet_only_routes() -> Router<Arc<NodeContext>> {
let wallet_only = Router::new()
.route("/wallet", post(wallet::create_wallet))
.route("/transactions", post(transaction::send_transaction));
Router::new()
.nest("/api/wallet", wallet_only)
.layer(axum::middleware::from_fn(require_wallet))
}
// Health checks — public, no auth
pub fn create_monitor_api_routes() -> Router<Arc<NodeContext>> {
Router::new()
.route("/health", get(health::health_check))
.route("/health/live", get(health::liveness))
.route("/health/ready", get(health::readiness))
}
The separation between admin and wallet routes is deliberate. Wallet applications get a limited-privilege API key that can only create wallets and submit transactions. Admin tools get a separate key with full access. This follows the principle of least privilege — a compromised wallet key can't dump the entire blockchain or trigger mining operations.
Handler Patterns
Every handler follows the same structure: extract inputs, process the request through NodeContext, and return a typed JSON response. Here's the blockchain info handler:
pub async fn get_blockchain_info(
State(node): State<Arc<NodeContext>>,
) -> Result<Json<ApiResponse<BlockchainInfoResponse>>, StatusCode> {
let height = node.get_blockchain_height().await
.map_err(|e| {
error!("Failed to get blockchain height: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
let last_block = node.blockchain().get_last_block().await
.map_err(|e| {
error!("Failed to get last block: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
let mempool_size = node.get_mempool_size()
.map_err(|e| {
error!("Failed to get mempool size: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
let info = BlockchainInfoResponse {
height,
difficulty: 1,
total_blocks: height,
mempool_size,
last_block_hash: last_block.get_hash().to_string(),
last_block_timestamp: chrono::Utc::now(),
};
Ok(Json(ApiResponse::success(info)))
}
The State(node) extractor gives the handler a reference to the blockchain node. Axum handles the extraction at compile time — if the type doesn't match what .with_state() provided, you get a compile error, not a runtime panic.
For endpoints that take path parameters, Axum can deserialize directly into custom types:
pub async fn get_balance(
State(node): State<Arc<NodeContext>>,
Path(address): Path<WalletAddress>, // Custom type, not String
) -> Result<Json<ApiResponse<BalanceResponse>>, StatusCode> {
let balance = node.get_balance(&address).await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let utxo_set = UTXOSet::new(node.blockchain().clone());
let utxo_count = utxo_set.utxo_count(&address).await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(ApiResponse::success(BalanceResponse {
address: address.as_string(),
balance,
utxo_count,
updated_at: chrono::Utc::now(),
})))
}
Path(address): Path<WalletAddress> extracts the {address} segment and deserializes it into a WalletAddress. If the path segment doesn't parse as a valid wallet address, Axum returns a 400 before the handler even runs.
For POST endpoints, the Json extractor handles request body deserialization:
pub async fn send_transaction(
State(node): State<Arc<NodeContext>>,
Json(request): Json<SendTransactionRequest>,
) -> Result<Json<ApiResponse<SendBitCoinResponse>>, StatusCode> {
let txid = node.btc_transaction(
&request.from_address,
&request.to_address,
request.amount
).await.map_err(|e| {
error!("Failed to create transaction: {}", e);
StatusCode::BAD_REQUEST
})?;
info!(txid = %txid, "Transaction submitted successfully");
Ok(Json(ApiResponse::success(SendBitCoinResponse {
txid,
timestamp: chrono::Utc::now(),
})))
}
A note on the error mapping: in this handler, btc_transaction failures typically indicate client errors (invalid address, insufficient funds), so BAD_REQUEST is reasonable. A production API with more error granularity would use a custom error enum that maps different failure modes to appropriate status codes — 400 for validation failures, 422 for business logic rejections, and 500 for system errors. We cover that pattern in the book's error handling chapter.
The pattern is consistent across every handler: extract, process, respond. This consistency makes the codebase predictable — once you've read one handler, you can read them all.
Authentication: Role-Based API Keys
Authentication is implemented as Axum middleware that checks the X-API-Key header and determines the caller's role:
pub async fn require_role(
mut req: axum::http::Request<axum::body::Body>,
required: Role,
next: axum::middleware::Next,
) -> Result<axum::response::Response, StatusCode> {
// Extract API key from header
let key = req.headers().get("X-API-Key")
.and_then(|h| h.to_str().ok());
// Determine caller's role
let caller_role = match key {
Some(k) if is_admin_key(k) => Role::Admin,
Some(k) if is_wallet_key(k) => Role::Wallet,
_ => return Err(StatusCode::UNAUTHORIZED),
};
// Check authorization (admin can access wallet routes)
let allowed = caller_role == required
|| (caller_role == Role::Admin && required == Role::Wallet);
if !allowed {
return Err(StatusCode::FORBIDDEN);
}
// Attach role to request extensions for downstream handlers
req.extensions_mut().insert(caller_role);
Ok(next.run(req).await)
}
The role hierarchy is simple: Admin has full access, Wallet has limited access, and unauthenticated users can only reach health checks. The req.extensions_mut().insert(caller_role) line attaches the resolved role to the request so handlers can inspect it if needed — for example, to filter response data based on privilege level.
Convenience wrappers make it clean to apply per-route:
pub async fn require_admin(
req: axum::http::Request<axum::body::Body>,
next: axum::middleware::Next,
) -> Result<axum::response::Response, StatusCode> {
require_role(req, Role::Admin, next).await
}
pub async fn require_wallet(
req: axum::http::Request<axum::body::Body>,
next: axum::middleware::Next,
) -> Result<axum::response::Response, StatusCode> {
require_role(req, Role::Wallet, next).await
}
Keys are validated against environment variables (BITCOIN_API_ADMIN_KEY and BITCOIN_API_WALLET_KEY), with development defaults as fallbacks. In production, you'd set strong keys and ideally use a key management service.
CORS: Development vs Production
CORS configuration is split into two modes. Development allows everything:
pub fn create_cors_layer() -> CorsLayer {
CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any)
.expose_headers(Any)
.max_age(std::time::Duration::from_secs(86400))
}
Production restricts to specific origins:
pub fn create_cors_layer_with_origins(origins: Vec<String>) -> CorsLayer {
let mut cors = CorsLayer::new()
.allow_methods(Any)
.allow_headers(Any)
.expose_headers(Any)
.max_age(std::time::Duration::from_secs(86400));
for origin in origins {
if let Ok(parsed) = origin.parse::<axum::http::HeaderValue>() {
cors = cors.allow_origin(parsed);
}
}
cors
}
The 24-hour max_age caches preflight responses so browsers don't re-send OPTIONS requests for every API call. This matters for performance — our desktop clients make dozens of requests per second when polling for blockchain updates.
Rate Limiting: Token Bucket with Redis
Rate limiting uses a Redis-backed token bucket algorithm. Each client gets a bucket of tokens; every request consumes one. Tokens refill at a configured rate. When the bucket is empty, the server responds with 429 Too Many Requests.
Configuration lives in a TOML file, allowing per-endpoint tuning:
[rate_limiter]
redis_addr = "127.0.0.1:6379"
ip_whitelist = ["127.0.0.1"]
# Global per-IP limit: burst of 20 requests, refill 1 token every 6 seconds
[[rate_limiter.limiter]]
strategy = "ip"
global_bucket = { tokens_count = 20, add_tokens_every = 6 }
# Per-URL limits with tighter control on expensive endpoints
[[rate_limiter.limiter]]
strategy = "url"
global_bucket = { tokens_count = 60, add_tokens_every = 1 }
buckets_per_value = [
{ value = "/api/mining/generate", tokens_count = 2, add_tokens_every = 60 },
]
The mining endpoint gets only 2 requests per minute — generating blocks is CPU-intensive and should be throttled aggressively. General endpoints get 60 requests per second, which handles normal client polling comfortably.
Response Envelope: One Shape for Everything
Every API response uses the same generic envelope:
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct ApiResponse<T> {
pub success: bool,
pub data: Option<T>,
pub error: Option<String>,
pub timestamp: DateTime<Utc>,
}
impl<T> ApiResponse<T> {
pub fn success(data: T) -> Self {
Self {
success: true,
data: Some(data),
error: None,
timestamp: Utc::now(),
}
}
pub fn error(error: String) -> Self {
Self {
success: false,
data: None,
error: Some(error),
timestamp: Utc::now(),
}
}
}
Clients always know the shape of the response. Parse success first — if true, data is present and typed. If false, error explains what went wrong. The timestamp field is useful for debugging latency and cache staleness.
This consistency pays off in client code. Our Tauri desktop app has a single invoke() wrapper that handles the envelope:
const response = await invoke<ApiResponse<T>>('api_call', { params });
if (!response.success) throw new Error(response.error);
return response.data;
Error Handling: Safe for Clients, Detailed for Operators
The error handling middleware catches all errors and ensures clients never see stack traces or internal paths:
async fn handle_errors(
request: axum::http::Request<axum::body::Body>,
next: axum::middleware::Next,
) -> Result<axum::response::Response, StatusCode> {
let response = next.run(request).await;
if response.status().is_server_error() || response.status().is_client_error() {
let (parts, body) = response.into_parts();
let body_bytes = axum::body::to_bytes(body, usize::MAX)
.await.unwrap_or_default();
// Log the full error for operators
tracing::error!(
"[handle_errors]: Error response ({}): {}",
parts.status,
String::from_utf8_lossy(&body_bytes)
);
// Sanitize internal server errors for clients
if parts.status == StatusCode::INTERNAL_SERVER_ERROR {
let safe_error = ErrorResponse {
error: "Internal Server Error".to_string(),
message: "An unexpected error occurred".to_string(),
status_code: 500,
timestamp: chrono::Utc::now(),
};
return Ok(Json(ApiResponse::<()>::error(
serde_json::to_string(&safe_error)
.unwrap_or_else(|_| "Unknown error".to_string()),
)).into_response());
}
}
Ok(response)
}
The split is intentional: operators see the full error context in logs (including the original error message, status code, and request context). Clients see a sanitized message that doesn't reveal anything about the internal architecture.
Health Checks: Three Endpoints, Three Purposes
We expose three health endpoints, each serving a different consumer:
// Full health check with metrics — for monitoring dashboards
pub async fn health_check(
State(node): State<Arc<NodeContext>>,
) -> Result<Json<ApiResponse<HealthResponse>>, StatusCode> {
let height = node.get_blockchain_height().await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let connected_peers = node.get_peer_count().unwrap_or(0);
Ok(Json(ApiResponse::success(HealthResponse {
status: "healthy".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
blockchain_height: height,
connected_peers,
// ... additional metrics
})))
}
// Liveness probe — for Kubernetes "is the process alive?"
pub async fn liveness() -> Result<Json<ApiResponse<String>>, StatusCode> {
Ok(Json(ApiResponse::success("alive".to_string())))
}
// Readiness probe — for Kubernetes "can this pod accept traffic?"
pub async fn readiness(
State(node): State<Arc<NodeContext>>,
) -> Result<Json<ApiResponse<String>>, StatusCode> {
match node.get_blockchain_height().await {
Ok(_) => Ok(Json(ApiResponse::success("ready".to_string()))),
Err(_) => Err(StatusCode::SERVICE_UNAVAILABLE),
}
}
Kubernetes uses /health/live to decide whether to restart a pod and /health/ready to decide whether to route traffic to it. The full /health endpoint gives monitoring systems (Prometheus, Datadog, etc.) detailed metrics. This three-endpoint pattern is a production best practice that works well beyond blockchain — any service deployed to containers benefits from it.
OpenAPI: Documentation That Can't Drift
The API specification is generated from Rust types using Utoipa. Every response model derives ToSchema, and the OpenAPI definition references them directly:
#[derive(OpenApi)]
#[openapi(
paths(
health::health_check,
blockchain::get_blockchain_info,
wallet::create_wallet,
transaction::send_transaction,
mining::get_mining_info,
// ... all endpoints
),
components(schemas(
BlockchainInfoResponse,
WalletResponse,
SendTransactionRequest,
// ... all models
)),
tags(
(name = "Health", description = "Health check endpoints"),
(name = "Blockchain", description = "Blockchain data and queries"),
(name = "Wallet", description = "Wallet management"),
(name = "Transactions", description = "Transaction operations"),
(name = "Mining", description = "Block generation"),
),
info(
title = "Blockchain API",
version = "0.1.0",
description = "A comprehensive blockchain node REST API"
),
)]
pub struct ApiDoc;
Swagger UI is served automatically at /swagger-ui:
pub fn create_swagger_ui() -> SwaggerUi {
SwaggerUi::new("/swagger-ui")
.url("/api-docs/openapi.json", ApiDoc::openapi())
}
Because the spec is generated from the same types the handlers use, it can't drift out of sync. If you add a field to BlockchainInfoResponse, it appears in the OpenAPI spec automatically. If you add a new endpoint, you add it to the paths() list and the compiler reminds you if you forget the schema. This is a significant advantage over maintaining a separate OpenAPI YAML file.
Structured Logging with Tracing
The tracing crate is initialized once at server startup — typically in main() with a subscriber that formats output as structured JSON in production or human-readable text in development:
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env()) // RUST_LOG=info,bitcoin=debug
.json() // Structured JSON for production log aggregation
.init();
With that in place, every handler logs operations with structured key-value pairs:
use tracing::{error, info, instrument};
// Structured logging with context
info!(
txid = %txid,
from = %from_address,
to = %to_address,
amount = amount,
"Transaction submitted successfully"
);
// Error logging preserves full context
error!(
error = %e,
txid = %txid,
from = %from_address,
"Failed to process transaction"
);
// Spans automatically add context to all nested logs
#[instrument]
async fn process_transaction(txid: String) {
info!("Processing transaction");
// ↑ This log automatically includes txid from the span
}
Structured logs are queryable. In production, you can filter for all failed transactions, all requests from a specific address, or all operations that took longer than a threshold — without parsing log strings.
Request Validation with Derive Macros
Request models use the validator crate for declarative validation:
#[derive(Debug, Serialize, Deserialize, Validate, ToSchema)]
pub struct SendTransactionRequest {
pub from_address: WalletAddress,
pub to_address: WalletAddress,
#[validate(range(min = 1, message = "Amount must be greater than 0"))]
pub amount: i32,
}
#[derive(Debug, Serialize, Deserialize, Validate, ToSchema)]
pub struct BlockQuery {
#[validate(range(min = 0, message = "Page must be 0 or greater"))]
pub page: Option<u32>,
#[validate(range(min = 1, max = 100, message = "Limit must be 1-100"))]
pub limit: Option<u32>,
pub hash: Option<String>,
}
Validation rules are declared next to the fields they constrain. The Validate derive macro generates a .validate() method that checks all rules at once. In a handler, you call it before processing:
pub async fn send_transaction(
State(node): State<Arc<NodeContext>>,
Json(request): Json<SendTransactionRequest>,
) -> Result<Json<ApiResponse<SendBitCoinResponse>>, StatusCode> {
// Validate before processing
request.validate().map_err(|_| StatusCode::BAD_REQUEST)?;
// ... proceed with validated request
}
Combined with ToSchema, the validation constraints also appear in the OpenAPI documentation — clients can see the allowed ranges before making requests.
Putting It All Together
Here's a curl session that exercises the main endpoints:
# Health check (no auth required)
curl http://localhost:8080/api/admin/health
# Get blockchain info (admin auth)
curl -H "X-API-Key: admin-secret" \
http://localhost:8080/api/admin/blockchain
# Create a wallet (wallet auth is sufficient)
curl -X POST \
-H "X-API-Key: wallet-secret" \
-H "Content-Type: application/json" \
-d '{"name": "my-wallet"}' \
http://localhost:8080/api/wallet/wallet
# Send a transaction
curl -X POST \
-H "X-API-Key: wallet-secret" \
-H "Content-Type: application/json" \
-d '{"from_address": "...", "to_address": "...", "amount": 50}' \
http://localhost:8080/api/wallet/transactions
# Mine a block (admin only)
curl -X POST \
-H "X-API-Key: admin-secret" \
-H "Content-Type: application/json" \
-d '{"address": "...", "nblocks": 1}' \
http://localhost:8080/api/admin/mining/generatetoaddress
# Interactive API docs
open http://localhost:8080/swagger-ui
Key Takeaways
After building this API, a few patterns stand out as universally applicable:
Use Axum's type system aggressively. Custom types for path parameters (WalletAddress instead of String), typed state extraction, and the ApiResponse<T> envelope catch entire categories of bugs at compile time. The initial investment in types pays off in fewer runtime errors and more self-documenting code.
Separate auth by route group, not globally. Health checks should always be public. Admin and wallet operations need different privilege levels. Axum's .layer() on nested routers makes this natural — you apply middleware where it belongs, not everywhere.
Rate limit by endpoint, not just by IP. A flat per-IP limit doesn't account for the fact that mining operations are 100x more expensive than health checks. Per-endpoint configuration lets you protect expensive operations without throttling cheap ones.
Generate documentation from code. Any documentation that exists separately from the implementation will drift. Utoipa's derive macros guarantee that the OpenAPI spec matches the actual types and endpoints. Swagger UI gives you interactive testing for free.
Three health endpoints, not one. Kubernetes needs liveness and readiness as separate signals. Monitoring dashboards need detailed metrics. One endpoint can't serve all three consumers well.
Explore the Source Code
The complete source code for the API layer is open source:
Full repository: github.com/bkunyiha/rust-blockchain
API source: bitcoin/src/web/
The same API serves both the Iced and Tauri desktop clients — you can see how they consume it in the Iced admin UI and Tauri admin UI.
This post is adapted from Rust Blockchain: A Full-Stack Implementation Guide — a 33-chapter book covering Axum, Iced, Tauri 2, Tokio, SQLCipher, Docker, and Kubernetes through one cohesive project.
Available now on Amazon (paperback + Kindle + hardcover), Gumroad (PDF + EPUB), and Leanpub (pay what you want).
Free resource: Clone the Starter Template
Follow buildwithrust.com for weekly posts on building production systems in Rust.




