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
49 changes: 41 additions & 8 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,9 @@ Percentage fields (`percent`, `target_percent`) are integers

### Naming

Boards and sources are identified by a URL-friendly `name` field
(e.g. `bitaxe-e2f56f9b`). These names appear in URL paths for
single-resource endpoints like `/api/v0/boards/{name}`.
Boards and sources are identified by a URL-friendly `name`
field (e.g. `bitaxe-e2f56f9b`). These names appear in URL paths
for single-resource endpoints like `/api/v0/boards/{name}`.

## Endpoints

Expand All @@ -92,12 +92,38 @@ table is a summary and may not be exhaustive.
| GET | `/boards` | List connected boards |
| GET | `/boards/{name}` | Single board detail |

### Config

| Method | Path | Description |
|--------|-----------|--------------------------|
| GET | `/config` | Full configuration tree |

Includes each pool's password in plaintext -- there is no
redaction. The API has no authentication, so anyone who can
reach it can read it.

### Sources

| Method | Path | Description |
|--------|-------------------|----------------------|
| GET | `/sources` | List job sources |
| GET | `/sources/{name}` | Single source detail |
| Method | Path | Description |
|--------|-------------------|-------------------------------|
| GET | `/sources` | List configured job sources |
| GET | `/sources/{name}` | Single source detail |

Same data as the `sources` field of `/config`, addressable by
`name`. Each entry carries a `kind` field identifying what kind
of source it is; `"stratum_v1"` is the only kind today, with
fields matching a Stratum pool (`url`, `username`, `password`).
"Source" is the term the rest of the codebase already uses for
this concept, and it covers more than pool connections -- the
dummy source used when none is configured, and, over time,
other protocols (Stratum v2 and beyond). `kind` exists so a
client can tell sources apart, and so adding a source kind
later is additive rather than a breaking change to this
endpoint.

A source's `name` is assigned automatically today (`a`, `b`,
`c`, ... by position) since there's no way to name one
explicitly yet.

### Health

Expand All @@ -109,8 +135,15 @@ All paths are relative to `/api/v0`.

## Types

The request and response types are defined in Rust in the
Most request and response types are defined in Rust in the
`api_client::types` module
(`mujina-miner/src/api_client/types.rs`). These types are the
shared contract between the server and its clients (CLI, TUI).
The OpenAPI schema is derived from them automatically.

`/config` and `/sources` are the exception: they serialize
`config::Config`, `config::SourceConfig`
(`mujina-miner/src/config.rs`), and
`stratum_v1::StratumV1PoolConfig`
(`mujina-miner/src/stratum_v1/client.rs`) directly, rather than a
separate view type.
149 changes: 92 additions & 57 deletions mujina-miner/src/api/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ use super::{
v0,
};
use crate::api_client::types::MinerTelemetry;
use crate::config::Config;

/// API server configuration.
#[derive(Debug, Clone)]
Expand All @@ -34,6 +35,7 @@ pub(crate) struct SharedState {
pub miner_telemetry_rx: watch::Receiver<MinerTelemetry>,
pub board_registry: Arc<Mutex<BoardRegistry>>,
pub scheduler_cmd_tx: mpsc::Sender<SchedulerCommand>,
pub config: Arc<Config>,
}

impl SharedState {
Expand Down Expand Up @@ -65,6 +67,7 @@ pub async fn serve(
miner_telemetry_rx: watch::Receiver<MinerTelemetry>,
mut board_reg_rx: mpsc::Receiver<BoardRegistration>,
scheduler_cmd_tx: mpsc::Sender<SchedulerCommand>,
config_tree: Config,
) -> Result<()> {
let board_registry = Arc::new(Mutex::new(BoardRegistry::new()));

Expand All @@ -79,7 +82,12 @@ pub async fn serve(
}
});

let app = build_router(miner_telemetry_rx, board_registry, scheduler_cmd_tx);
let app = build_router(
miner_telemetry_rx,
board_registry,
scheduler_cmd_tx,
Arc::new(config_tree),
);

let listener = TcpListener::bind(&config.bind_addr).await?;
let actual_addr = listener.local_addr()?;
Expand Down Expand Up @@ -110,11 +118,13 @@ pub(crate) fn build_router(
miner_telemetry_rx: watch::Receiver<MinerTelemetry>,
board_registry: Arc<Mutex<BoardRegistry>>,
scheduler_cmd_tx: mpsc::Sender<SchedulerCommand>,
config: Arc<Config>,
) -> Router {
let state = SharedState {
miner_telemetry_rx,
board_registry,
scheduler_cmd_tx,
config,
};

let (router, api) = OpenApiRouter::new()
Expand Down Expand Up @@ -143,6 +153,8 @@ mod tests {
use crate::api::commands::SchedulerCommand;
use crate::api::registry::BoardRegistration;
use crate::api_client::types::{BoardTelemetry, SourceTelemetry};
use crate::config::{SourceConfig, SourceKind};
use crate::stratum_v1::StratumV1PoolConfig;

/// Test fixtures returned by the router builder.
struct TestFixtures {
Expand All @@ -158,6 +170,7 @@ mod tests {
fn build_test_router(
miner_state: MinerTelemetry,
board_states: Vec<BoardTelemetry>,
config: Config,
) -> TestFixtures {
let (miner_tx, miner_rx) = watch::channel(miner_state);
let (cmd_tx, cmd_rx) = mpsc::channel::<SchedulerCommand>(16);
Expand All @@ -171,7 +184,12 @@ mod tests {
}

TestFixtures {
router: build_router(miner_rx, Arc::new(Mutex::new(registry)), cmd_tx),
router: build_router(
miner_rx,
Arc::new(Mutex::new(registry)),
cmd_tx,
Arc::new(config),
),
_board_senders: board_senders,
_miner_tx: miner_tx,
_cmd_rx: cmd_rx,
Expand All @@ -191,7 +209,7 @@ mod tests {

#[tokio::test]
async fn health_returns_ok() {
let fixtures = build_test_router(MinerTelemetry::default(), vec![]);
let fixtures = build_test_router(MinerTelemetry::default(), vec![], Config::default());
let (status, body) = get(fixtures.router.clone(), "/api/v0/health").await;
assert_eq!(status, 200);
assert_eq!(body, "OK");
Expand All @@ -215,7 +233,7 @@ mod tests {
model: "TestModel".into(),
..Default::default()
};
let fixtures = build_test_router(miner_state, vec![board]);
let fixtures = build_test_router(miner_state, vec![board], Config::default());

let (status, body) = get(fixtures.router.clone(), "/api/v0/miner").await;
assert_eq!(status, 200);
Expand Down Expand Up @@ -244,7 +262,7 @@ mod tests {
..Default::default()
},
];
let fixtures = build_test_router(MinerTelemetry::default(), boards);
let fixtures = build_test_router(MinerTelemetry::default(), boards, Config::default());

let (status, body) = get(fixtures.router.clone(), "/api/v0/boards").await;
assert_eq!(status, 200);
Expand All @@ -263,7 +281,7 @@ mod tests {
serial: Some("abc123".into()),
..Default::default()
};
let fixtures = build_test_router(MinerTelemetry::default(), vec![board]);
let fixtures = build_test_router(MinerTelemetry::default(), vec![board], Config::default());

let (status, body) = get(fixtures.router.clone(), "/api/v0/boards/bitaxe-abc123").await;
assert_eq!(status, 200);
Expand All @@ -275,90 +293,107 @@ mod tests {

#[tokio::test]
async fn board_by_name_returns_404_when_missing() {
let fixtures = build_test_router(MinerTelemetry::default(), vec![]);
let fixtures = build_test_router(MinerTelemetry::default(), vec![], Config::default());
let (status, _body) = get(fixtures.router.clone(), "/api/v0/boards/nonexistent").await;
assert_eq!(status, 404);
}

#[tokio::test]
async fn sources_returns_list() {
let miner_state = MinerTelemetry {
sources: vec![
SourceTelemetry {
name: "pool-a".into(),
url: Some("stratum+tcp://a:3333".into()),
..Default::default()
},
SourceTelemetry {
name: "pool-b".into(),
url: None,
async fn config_returns_configured_sources() {
let config = Config {
sources: vec![SourceConfig {
name: "a".into(),
kind: SourceKind::StratumV1(StratumV1PoolConfig {
url: "stratum+tcp://pool.example:3333".into(),
username: "alice.worker1".into(),
password: Some("hunter2".into()),
..Default::default()
},
],
..Default::default()
}),
}],
};
let fixtures = build_test_router(miner_state, vec![]);
let fixtures = build_test_router(MinerTelemetry::default(), vec![], config);

let (status, body) = get(fixtures.router.clone(), "/api/v0/sources").await;
let (status, body) = get(fixtures.router.clone(), "/api/v0/config").await;
assert_eq!(status, 200);

let sources: Vec<SourceTelemetry> = serde_json::from_str(&body).unwrap();
assert_eq!(sources.len(), 2);
assert_eq!(sources[0].name, "pool-a");
assert_eq!(sources[0].url.as_deref(), Some("stratum+tcp://a:3333"));
assert_eq!(sources[1].name, "pool-b");
assert_eq!(sources[1].url, None);
let json: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(json["sources"][0]["name"], "a");
assert_eq!(json["sources"][0]["kind"], "stratum_v1");
assert_eq!(json["sources"][0]["url"], "stratum+tcp://pool.example:3333");
assert_eq!(json["sources"][0]["username"], "alice.worker1");
assert_eq!(json["sources"][0]["password"], "hunter2");
}

#[tokio::test]
async fn source_by_name_returns_match() {
let miner_state = MinerTelemetry {
sources: vec![SourceTelemetry {
name: "my-pool".into(),
url: Some("stratum+tcp://pool:3333".into()),
..Default::default()
async fn sources_returns_configured_sources() {
let config = Config {
sources: vec![SourceConfig {
name: "a".into(),
kind: SourceKind::StratumV1(StratumV1PoolConfig {
url: "stratum+tcp://pool.example:3333".into(),
username: "alice.worker1".into(),
password: None,
..Default::default()
}),
}],
..Default::default()
};
let fixtures = build_test_router(miner_state, vec![]);
let fixtures = build_test_router(MinerTelemetry::default(), vec![], config);

let (status, body) = get(fixtures.router.clone(), "/api/v0/sources/my-pool").await;
let (status, body) = get(fixtures.router.clone(), "/api/v0/sources").await;
assert_eq!(status, 200);

let source: SourceTelemetry = serde_json::from_str(&body).unwrap();
assert_eq!(source.name, "my-pool");
assert_eq!(source.url.as_deref(), Some("stratum+tcp://pool:3333"));
let json: serde_json::Value = serde_json::from_str(&body).unwrap();
let sources = json.as_array().unwrap();
assert_eq!(sources.len(), 1);
assert_eq!(sources[0]["name"], "a");
assert_eq!(sources[0]["kind"], "stratum_v1");
assert_eq!(sources[0]["url"], "stratum+tcp://pool.example:3333");
assert_eq!(sources[0]["username"], "alice.worker1");
assert!(sources[0]["password"].is_null());
}

#[tokio::test]
async fn source_by_name_returns_404_when_missing() {
let fixtures = build_test_router(MinerTelemetry::default(), vec![]);
let (status, _body) = get(fixtures.router.clone(), "/api/v0/sources/nonexistent").await;
assert_eq!(status, 404);
async fn sources_returns_empty_when_no_pools_configured() {
let fixtures = build_test_router(MinerTelemetry::default(), vec![], Config::default());

let (status, body) = get(fixtures.router.clone(), "/api/v0/sources").await;
assert_eq!(status, 200);
assert_eq!(body, "[]");
}

#[tokio::test]
async fn source_difficulty_serializes_as_f64() {
let miner_state = MinerTelemetry {
sources: vec![SourceTelemetry {
name: "pool".into(),
difficulty: Some(2048.5),
..Default::default()
async fn source_by_name_returns_match() {
let config = Config {
sources: vec![SourceConfig {
name: "a".into(),
kind: SourceKind::StratumV1(StratumV1PoolConfig {
url: "stratum+tcp://pool.example:3333".into(),
username: "alice.worker1".into(),
password: None,
..Default::default()
}),
}],
..Default::default()
};
let fixtures = build_test_router(miner_state, vec![]);
let fixtures = build_test_router(MinerTelemetry::default(), vec![], config);

let (status, body) = get(fixtures.router.clone(), "/api/v0/sources/pool").await;
let (status, body) = get(fixtures.router.clone(), "/api/v0/sources/a").await;
assert_eq!(status, 200);

let source: SourceTelemetry = serde_json::from_str(&body).unwrap();
assert_eq!(source.difficulty, Some(2048.5));
let json: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(json["name"], "a");
assert_eq!(json["url"], "stratum+tcp://pool.example:3333");
}

#[tokio::test]
async fn source_by_name_returns_404_when_missing() {
let fixtures = build_test_router(MinerTelemetry::default(), vec![], Config::default());
let (status, _body) = get(fixtures.router.clone(), "/api/v0/sources/nonexistent").await;
assert_eq!(status, 404);
}

#[tokio::test]
async fn unknown_route_returns_404() {
let fixtures = build_test_router(MinerTelemetry::default(), vec![]);
let fixtures = build_test_router(MinerTelemetry::default(), vec![], Config::default());
let (status, _body) = get(fixtures.router.clone(), "/api/v0/nope").await;
assert_eq!(status, 404);
}
Expand Down
Loading
Loading