Skip to main content

eneros-api

Crate Index

eneros-api

eneros-api provides EnerOS’s CLI tool enerosctl and four API services (REST / GraphQL / WebSocket / SSE), serving as the primary entry point for user-system interaction. The HTTP service is based on axum, the CLI on clap, and both share the same business logic. All write operations go through secondary validation by eneros-gateway before being persisted.

Responsibilities

eneros-api handles the following core responsibilities:

  • REST API: /api/v1/* endpoints covering network management, Load Flow calculation, Agent control, time series query, and command execution
  • GraphQL API: /graphql endpoint supporting declarative queries and field pruning, provided by the eneros-graphql crate
  • WebSocket Push: Real-time event streams, subscribing to topology changes, alarms, Agent status, etc.
  • SSE Push: Server-Sent Events, browser-friendly real-time data streams
  • CLI Tool: enerosctl command-line tool, sharing business logic with the REST API
  • OpenAPI 3.0: Auto-generated OpenAPI schema for easy client SDK generation
  • Unified Authentication: Dual authentication with Bearer Token + mTLS
  • Rate Limiting: Shares rate limiting policies with eneros-gateway

Key Types and Interfaces

ApiServer

use axum::Router;
use eneros_gateway::SafetyGateway;
use eneros_topology::NetworkGraph;

pub struct ApiServer {
    router: Router,
    gateway: SafetyGateway,
    config: ApiConfig,
}

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ApiConfig {
    pub bind: String,
    pub workers: usize,
    pub enable_graphql: bool,
    pub enable_websocket: bool,
    pub enable_sse: bool,
    pub cors_origins: Vec<String>,
    pub openapi_path: String,
}

impl Default for ApiConfig {
    fn default() -> Self {
        Self {
            bind: "0.0.0.0:8080".into(),
            workers: 4,
            enable_graphql: true,
            enable_websocket: true,
            enable_sse: true,
            cors_origins: vec!["*".into()],
            openapi_path: "/openapi.json".into(),
        }
    }
}

impl ApiServer {
    pub fn from_config(path: impl AsRef<Path>) -> Result<Self> {
        let text = std::fs::read_to_string(path.as_ref())?;
        let config: ApiConfig = toml::from_str(&text)?;
        Ok(Self {
            router: Self::build_router(&config),
            gateway: SafetyGateway::from_config("gateway.toml")?,
            config,
        })
    }

    pub fn with_config(config: ApiConfig) -> Self {
        Self {
            router: Self::build_router(&config),
            gateway: SafetyGateway::default(),
            config,
        }
    }

    fn build_router(config: &ApiConfig) -> Router {
        let mut router = Router::new()
            .route("/api/v1/networks", get(list_networks).post(create_network))
            .route("/api/v1/networks/:id", get(get_network).delete(delete_network))
            .route("/api/v1/powerflow/:network_id", post(run_powerflow))
            .route("/api/v1/agents", get(list_agents).post(create_agent))
            .route("/api/v1/agents/:id", get(get_agent).delete(delete_agent))
            .route("/api/v1/commands", post(execute_command))
            .route("/api/v1/timeseries/query", post(query_timeseries))
            .route("/api/v1/alarms", get(list_alarms))
            .route("/api/v1/health", get(health_check));

        if config.enable_graphql {
            router = router.route("/graphql", post(graphql_handler).get(graphql_playground));
        }
        if config.enable_websocket {
            router = router.route("/ws", get(websocket_handler));
        }
        if config.enable_sse {
            router = router.route("/api/v1/stream", get(sse_handler));
        }
        router
    }

    pub async fn listen(self, addr: &str) -> Result<()> {
        let listener = tokio::net::TcpListener::bind(addr).await?;
        axum::serve(listener, self.router).await?;
        Ok(())
    }
}

ApiClient

pub struct ApiClient {
    endpoint: String,
    token: Option<String>,
    http: reqwest::Client,
}

pub struct ApiClientBuilder {
    endpoint: Option<String>,
    token: Option<String>,
    timeout: std::time::Duration,
}

impl ApiClientBuilder {
    pub fn new() -> Self {
        Self {
            endpoint: None,
            token: None,
            timeout: std::time::Duration::from_secs(30),
        }
    }

    pub fn endpoint(mut self, e: impl Into<String>) -> Self {
        self.endpoint = Some(e.into());
        self
    }

    pub fn token(mut self, t: impl Into<String>) -> Self {
        self.token = Some(t.into());
        self
    }

    pub fn timeout(mut self, t: std::time::Duration) -> Self {
        self.timeout = t;
        self
    }

    pub fn build(self) -> Result<ApiClient> {
        let endpoint = self.endpoint.ok_or_else(|| EnerOSError::Internal("endpoint required".into()))?;
        let mut http = reqwest::Client::builder().timeout(self.timeout);
        if let Some(token) = &self.token {
            http = http.default_headers(headers_with_bearer(token));
        }
        Ok(ApiClient {
            endpoint,
            token: self.token,
            http: http.build()?,
        })
    }
}

impl ApiClient {
    pub fn builder() -> ApiClientBuilder {
        ApiClientBuilder::new()
    }

    pub async fn list_networks(&self) -> Result<Vec<NetworkSummary>> {
        let url = format!("{}/api/v1/networks", self.endpoint);
        let resp = self.http.get(&url).send().await?.json().await?;
        Ok(resp)
    }

    pub async fn run_powerflow(&self, network_id: &str) -> Result<PowerFlowResultDto> {
        let url = format!("{}/api/v1/powerflow/{}", self.endpoint, network_id);
        let resp = self.http.post(&url).send().await?.json().await?;
        Ok(resp)
    }

    pub async fn create_agent(&self, req: CreateAgentRequest) -> Result<AgentDto> {
        let url = format!("{}/api/v1/agents", self.endpoint);
        let resp = self.http.post(&url).json(&req).send().await?.json().await?;
        Ok(resp)
    }

    pub async fn execute_command(&self, cmd: CommandRequest) -> Result<CommandResultDto> {
        let url = format!("{}/api/v1/commands", self.endpoint);
        let resp = self.http.post(&url).json(&cmd).send().await?.json().await?;
        Ok(resp)
    }

    pub async fn query_timeseries(&self, q: TimeSeriesQuery) -> Result<Vec<PointDto>> {
        let url = format!("{}/api/v1/timeseries/query", self.endpoint);
        let resp = self.http.post(&url).json(&q).send().await?.json().await?;
        Ok(resp)
    }
}

CLI

use clap::{Parser, Subcommand};

#[derive(Parser)]
#[command(name = "enerosctl", version = "0.47.0", about = "EnerOS CLI")]
pub struct Cli {
    #[command(subcommand)]
    pub command: CliCommand,

    #[arg(long, default_value = "http://localhost:8080")]
    pub endpoint: String,

    #[arg(long)]
    pub token: Option<String>,
}

#[derive(Subcommand)]
pub enum CliCommand {
    /// Network management
    Network {
        #[command(subcommand)]
        action: NetworkAction,
    },
    /// Agent management
    Agent {
        #[command(subcommand)]
        action: AgentAction,
    },
    /// Load Flow calculation
    Powerflow {
        network_id: String,
        #[arg(long, default_value = "newton_raphson")]
        method: String,
    },
    /// Time series query
    Query {
        #[arg(long)]
        metric: String,
        #[arg(long)]
        from: String,
        #[arg(long)]
        to: String,
    },
    /// Real-time event stream (WebSocket)
    Stream {
        #[arg(long, default_value = "alarms")]
        topic: String,
    },
}

#[derive(Subcommand)]
pub enum NetworkAction {
    List,
    Create { name: String },
    Delete { id: String },
    Show { id: String },
}

#[derive(Subcommand)]
pub enum AgentAction {
    List,
    Create {
        #[arg(long)]
        r#type: String,
        #[arg(long)]
        name: String,
    },
    Delete { id: String },
    Start { id: String },
    Stop { id: String },
}

DTO Types

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct NetworkSummary {
    pub id: String,
    pub name: String,
    pub bus_count: usize,
    pub branch_count: usize,
    pub created_at: chrono::DateTime<chrono::Utc>,
}

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct PowerFlowResultDto {
    pub network_id: String,
    pub converged: bool,
    pub iterations: usize,
    pub duration_ms: f64,
    pub buses: Vec<BusSolutionDto>,
}

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct BusSolutionDto {
    pub id: String,
    pub voltage_magnitude: f64,
    pub voltage_angle_rad: f64,
    pub p_pu: f64,
    pub q_pu: f64,
}

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct CommandRequest {
    pub agent_id: String,
    pub action: String,
    pub params: serde_json::Value,
}

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct CommandResultDto {
    pub command_id: String,
    pub status: String,
    pub duration_ms: f64,
}

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct CreateAgentRequest {
    pub r#type: String,
    pub name: String,
    pub jurisdiction: Option<serde_json::Value>,
}

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct AgentDto {
    pub id: String,
    pub r#type: String,
    pub name: String,
    pub status: String,
}

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct TimeSeriesQuery {
    pub metric: String,
    pub from: chrono::DateTime<chrono::Utc>,
    pub to: chrono::DateTime<chrono::Utc>,
    pub aggregation: Option<String>,
}

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct PointDto {
    pub timestamp: chrono::DateTime<chrono::Utc>,
    pub value: f64,
}

Core API

MethodSignatureDescription
ApiServer::from_configfn from_config(path: impl AsRef<Path>) -> Result<Self>Load from TOML
ApiServer::with_configfn with_config(config: ApiConfig) -> SelfProgrammatic construction
ApiServer::listenasync fn listen(self, addr: &str) -> Result<()>Start HTTP service
ApiClient::builderfn builder() -> ApiClientBuilderConstruct client
ApiClient::list_networksasync fn list_networks(&self) -> Result<Vec<NetworkSummary>>List networks
ApiClient::run_powerflowasync fn run_powerflow(&self, network_id: &str) -> Result<PowerFlowResultDto>Trigger Load Flow
ApiClient::create_agentasync fn create_agent(&self, req: CreateAgentRequest) -> Result<AgentDto>Create Agent
ApiClient::execute_commandasync fn execute_command(&self, cmd: CommandRequest) -> Result<CommandResultDto>Execute command
ApiClient::query_timeseriesasync fn query_timeseries(&self, q: TimeSeriesQuery) -> Result<Vec<PointDto>>Time series query

Usage Examples

CLI Commands

# List all networks
enerosctl network list

# Create network
enerosctl network create --name "ieee14"

# View network details
enerosctl network show net_8f3a2b

# Create Agent
enerosctl agent create --type dispatch --name "dispatch_01"

# Start Agent
enerosctl agent start dispatch_01

# Trigger Load Flow calculation
enerosctl powerflow net_8f3a2b --method newton_raphson

# Time series query
enerosctl query \
  --metric branch_loading \
  --from 2026-07-06T00:00:00Z \
  --to 2026-07-06T08:00:00Z

# Subscribe to real-time alarm stream
enerosctl stream --topic alarms

Rust SDK Calls

use eneros_api::ApiClient;

let client = ApiClient::builder()
    .endpoint("http://localhost:8080")
    .token("<bearer-token>")
    .build()?;

let networks = client.list_networks().await?;
for n in &networks {
    println!("Network: {} ({} buses)", n.name, n.bus_count);
}

let result = client.run_powerflow("net_8f3a2b").await?;
println!(
    "Load Flow result: converged={} iterations={} duration={:.2}ms",
    result.converged, result.iterations, result.duration_ms
);

for bus in &result.buses {
    println!(
        "Bus {}: V={:.4} θ={:.4}rad",
        bus.id, bus.voltage_magnitude, bus.voltage_angle_rad
    );
}

Create Agent and Execute Command

use eneros_api::{ApiClient, CreateAgentRequest, CommandRequest};

let client = ApiClient::builder()
    .endpoint("http://localhost:8080")
    .token("<bearer-token>")
    .build()?;

let agent = client.create_agent(CreateAgentRequest {
    r#type: "dispatch".into(),
    name: "dispatch_01".into(),
    jurisdiction: None,
}).await?;

println!("Agent created: id={}", agent.id);

let result = client.execute_command(CommandRequest {
    agent_id: agent.id.clone(),
    action: "SetGeneration".into(),
    params: serde_json::json!({"bus": "bus_2", "mw": 50.0}),
}).await?;

println!("Command result: status={} duration={:.2}ms", result.status, result.duration_ms);

Time Series Query

use eneros_api::{ApiClient, TimeSeriesQuery};
use chrono::{Utc, Duration};

let client = ApiClient::builder()
    .endpoint("http://localhost:8080")
    .build()?;

let now = Utc::now();
let points = client.query_timeseries(TimeSeriesQuery {
    metric: "branch_loading".into(),
    from: now - Duration::hours(8),
    to: now,
    aggregation: Some("avg_5min".into()),
}).await?;

for p in &points {
    println!("{}: {:.2}%", p.timestamp, p.value);
}

Start API Service

use eneros_api::ApiServer;

let server = ApiServer::from_config("api.toml")?;
server.listen("0.0.0.0:8080").await?;

REST API Examples

# List networks
curl http://localhost:8080/api/v1/networks

# Trigger Load Flow
curl -X POST http://localhost:8080/api/v1/powerflow/net_8f3a2b

# Create Agent
curl -X POST http://localhost:8080/api/v1/agents \
  -H "Content-Type: application/json" \
  -d '{"type":"dispatch","name":"dispatch_01"}'

# Time series query
curl -X POST http://localhost:8080/api/v1/timeseries/query \
  -H "Content-Type: application/json" \
  -d '{"metric":"branch_loading","from":"2026-07-06T00:00:00Z","to":"2026-07-06T08:00:00Z"}'

# SSE real-time push
curl -N http://localhost:8080/api/v1/stream?topic=alarms

# GraphQL query
curl -X POST http://localhost:8080/graphql \
  -H "Content-Type: application/json" \
  -d '{"query":"{ networks { id name busCount } }"}'

REST API Endpoint Table

MethodPathDescription
GET/api/v1/networksList all networks
POST/api/v1/networksCreate network
GET/api/v1/networks/:idGet network details
DELETE/api/v1/networks/:idDelete network
POST/api/v1/powerflow/:network_idTrigger Load Flow calculation
GET/api/v1/agentsList Agents
POST/api/v1/agentsCreate Agent
GET/api/v1/agents/:idGet Agent details
DELETE/api/v1/agents/:idDelete Agent
POST/api/v1/commandsExecute command
POST/api/v1/timeseries/queryTime series query
GET/api/v1/alarmsList alarms
GET/api/v1/healthHealth check
GET/api/v1/streamSSE real-time push
GET/wsWebSocket event stream
POST/graphqlGraphQL query
GET/openapi.jsonOpenAPI 3.0 schema

Configuration Parameter Table

ApiConfig Fields

FieldTypeDefaultDescription
bindString”0.0.0.0:8080”Listen address
workersusize4Number of worker threads
enable_graphqlbooltrueEnable GraphQL
enable_websocketbooltrueEnable WebSocket
enable_ssebooltrueEnable SSE
cors_originsVec[”*“]CORS allowed origins
openapi_pathString”/openapi.json”OpenAPI schema path

Performance Metrics

Test environment: 4 cores / 8GB / Ubuntu 22.04 / Rust 1.78 release build.

OperationLatencyThroughput
Health check< 1ms50k req/s
List networks< 5ms10k req/s
Trigger Load Flow (IEEE-14)< 15ms100 req/s
Time series query (10k points)< 20ms5000 req/s
SSE push (1000 subscribers)< 1ms broadcast latency-
WebSocket push< 500μs-

Dependencies

Own Dependencies

[dependencies]
eneros-core = { path = "../core", version = "0.47.0" }
eneros-gateway = { path = "../gateway", version = "0.47.0" }
eneros-topology = { path = "../topology", version = "0.47.0" }
eneros-powerflow = { path = "../powerflow", version = "0.47.0" }
eneros-agent = { path = "../agent", version = "0.47.0" }
eneros-timeseries = { path = "../timeseries", version = "0.47.0" }
eneros-graphql = { path = "../graphql", version = "0.47.0", optional = true }
axum = { version = "0.7", features = ["ws"] }
clap = { version = "4", features = ["derive"] }
reqwest = { version = "0.12", features = ["json"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
toml = "0.8"
tracing = "0.1"
chrono = { version = "0.4", features = ["serde"] }

[features]
default = ["graphql"]
graphql = ["dep:eneros-graphql"]

Depended On By

  • eneros-dashboard — Dashboard embeds API
  • eneros-edge — Edge nodes run API instances

Version and Compatibility

eneros-api VersionEnerOS VersionKey Changes
0.47.x0.47.xAdded SSE and GraphQL endpoints
0.46.x0.46.xWebSocket event stream
0.45.x (LTS)0.45.xLTS version, security updates until 2028