EnerOS v0.10.0
Release Date: October 6, 2024 Codename: Horizon Git Tag: v0.10.0 Support Status: Internal Preview Total Crates: 9 Test Cases: 1463
Version Overview
EnerOS v0.10.0 “Horizon” introduces the REST API interface layer, enabling EnerOS to provide grid analysis and control services externally via the HTTP protocol. This version releases the eneros-api crate, built on the axum Web framework, providing three major capabilities: OpenAPI 3.0 document auto-generation, JWT authentication and authorization, and API version management. This is a key step for EnerOS from an “embedded runtime” to a “platform service” — external systems (SCADA, EMS, DMS, operations portals, mobile apps) can now access EnerOS’s topology, power flow, constraints, Agent, and time series data capabilities through standard HTTP/JSON interfaces.
axum is a Rust Web framework officially maintained by the Tokio team, characterized by “no macros, type safety, and seamless integration with the Tower middleware ecosystem”. EnerOS chose axum over actix-web or rocket for three reasons: First, axum is naturally compatible with EnerOS’s existing tokio async runtime, requiring no additional runtime; Second, axum’s extractor pattern combines closely with Rust’s type system, capturing parameter parsing errors at compile time; Third, axum’s Tower middleware ecosystem provides mature authentication, rate limiting, logging, and tracing components, avoiding reinventing the wheel. The API layer of v0.10.0 is built entirely on the Tower middleware chain, allowing flexible plugging and unplugging.
OpenAPI 3.0 document auto-generation is implemented through the utoipa crate. Developers only need to add #[utoipa::path] macro annotations on handler functions and register them in the OpenApi derive to automatically generate JSON documents conforming to the OpenAPI 3.0 specification, and provide an interactive API browsing interface through Swagger UI or Redoc. This feature significantly reduces API documentation maintenance costs — documentation and code are from the same source and never outdated. JWT authentication uses the jsonwebtoken crate, supporting both HS256/RS256 signature algorithms, with tokens containing user identity, roles, and permission claims, integrated with v0.2.0’s capability model — the API layer converts permission claims in JWT into kernel capabilities, achieving end-to-end permission transmission.
Key Data
| Metric | Value | Description |
|---|---|---|
| API endpoint count | 42 | REST resources |
| OpenAPI document size | 38 KB | Auto-generated |
| Request latency P99 | 4.2 ms | Excluding business computation |
| Concurrent connections | 4096 | Single instance |
| JWT verification time | 180 μs | RS256 |
| Supported auth algorithms | 2 | HS256/RS256 |
New Features
1. REST API Interface Layer (axum Integration)
// crates/eneros-api/src/app.rs
use axum::{Router, routing::{get, post, put, delete}, middleware, extract::State};
use std::sync::Arc;
/// API application
pub struct ApiApp {
router: Router,
state: Arc<AppState>,
}
/// Application shared state
pub struct AppState {
pub network: Arc<RwLock<Network>>,
pub agent_manager: Arc<RwLock<AgentManager>>,
pub timeseries: Arc<RwLock<TimeSeriesEngine>>,
pub constraint_engine: Arc<ConstraintEngine>,
}
impl ApiApp {
pub fn new(state: Arc<AppState>) -> Self {
let router = Router::new()
// Topology endpoints
.route("/api/v1/topology/buses", get(list_buses))
.route("/api/v1/topology/buses/:id", get(get_bus))
.route("/api/v1/topology/branches", get(list_branches))
.route("/api/v1/topology/islands", get(list_islands))
// Power flow endpoints
.route("/api/v1/powerflow/solve", post(solve_powerflow))
.route("/api/v1/powerflow/results/:id", get(get_powerflow_result))
// Constraint endpoints
.route("/api/v1/constraints", get(list_constraints))
.route("/api/v1/constraints/check", post(check_constraints))
// Agent endpoints
.route("/api/v1/agents", get(list_agents).post(create_agent))
.route("/api/v1/agents/:id", get(get_agent).delete(delete_agent))
.route("/api/v1/agents/:id/messages", post(send_message))
// Time series endpoints
.route("/api/v1/timeseries/query", post(query_timeseries))
.route("/api/v1/timeseries/write", post(write_timeseries))
// Health checks
.route("/health", get(health_check))
.route("/ready", get(readiness_check))
// Middleware chain
.layer(middleware::from_fn(auth_middleware))
.layer(middleware::from_fn(logging_middleware))
.layer(middleware::from_fn(rate_limit_middleware))
.with_state(state);
Self { router, state }
}
pub async fn serve(self, addr: &str) -> EnerOSResult<()> {
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, self.router).await?;
Ok(())
}
}
2. OpenAPI Document Auto-Generation
// crates/eneros-api/src/openapi.rs
use utoipa::{OpenApi, ToSchema};
use utoipa::openapi::security::HttpAuthScheme;
#[derive(OpenApi)]
#[openapi(
paths(
crate::handlers::topology::list_buses,
crate::handlers::topology::get_bus,
crate::handlers::powerflow::solve_powerflow,
crate::handlers::constraint::check_constraints,
crate::handlers::agent::list_agents,
crate::handlers::agent::create_agent,
crate::handlers::timeseries::query_timeseries,
),
components(schemas(
Bus, Branch, Generator,
PowerFlowResult, PowerFlowConfig,
ConstraintResult, AgentInfo, AgentManifest,
QueryRequest, QueryResponse,
ErrorResponse,
)),
security(("bearerAuth" = [])),
info(
title = "EnerOS API",
version = "0.10.0",
description = "EnerOS Power/Energy-Native AgentOS REST API",
license(name = "MIT"),
)
)]
pub struct ApiDoc;
impl ApiDoc {
/// Get OpenAPI JSON
pub fn json() -> String {
serde_json::to_string_pretty(&Self::openapi()).unwrap()
}
/// Provide Swagger UI
pub fn swagger_ui() -> axum::Router {
utoipa_swagger_ui::SwaggerUi::new("/docs/*tail")
.url("/api-docs/openapi.json", Self::openapi())
.into()
}
}
Handler annotation example:
// crates/eneros-api/src/handlers/topology.rs
use axum::extract::{Path, Query, State};
use axum::Json;
use utoipa::{ToSchema, IntoParams};
use serde::{Deserialize, Serialize};
/// Bus information
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct BusResponse {
pub id: u32,
pub name: String,
pub voltage_level: f64,
pub bus_type: String,
pub base_kv: f64,
}
/// List all buses
#[utoipa::path(
get,
path = "/api/v1/topology/buses",
responses(
(status = 200, description = "Success", body = [BusResponse]),
(status = 401, description = "Unauthorized", body = ErrorResponse),
(status = 500, description = "Internal error", body = ErrorResponse),
),
security(("bearerAuth" = [])),
tag = "topology"
)]
pub async fn list_buses(
State(state): State<Arc<AppState>>,
claims: AuthClaims,
) -> Result<Json<Vec<BusResponse>>, ApiError> {
let network = state.network.read().await;
let buses = network.buses().map(|b| BusResponse {
id: b.id.0,
name: b.name.clone(),
voltage_level: b.voltage_level.kv(),
bus_type: format!("{:?}", b.bus_type),
base_kv: b.base_kv,
}).collect();
Ok(Json(buses))
}
/// Get a single bus
#[utoipa::path(
get,
path = "/api/v1/topology/buses/{id}",
params(("id" = u32, Path, description = "Bus ID")),
responses(
(status = 200, description = "Success", body = BusResponse),
(status = 404, description = "Not found", body = ErrorResponse),
),
security(("bearerAuth" = [])),
tag = "topology"
)]
pub async fn get_bus(
State(state): State<Arc<AppState>>,
claims: AuthClaims,
Path(id): Path<u32>,
) -> Result<Json<BusResponse>, ApiError> {
let network = state.network.read().await;
let bus = network.bus(BusId(id)).ok_or(ApiError::NotFound)?;
Ok(Json(BusResponse {
id: bus.id.0,
name: bus.name.clone(),
voltage_level: bus.voltage_level.kv(),
bus_type: format!("{:?}", bus.bus_type),
base_kv: bus.base_kv,
}))
}
3. Authentication and Authorization (JWT)
// crates/eneros-api/src/auth.rs
use axum::extract::Request;
use axum::http::header::AUTHORIZATION;
use axum::middleware::Next;
use axum::response::Response;
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
/// JWT Claims
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthClaims {
pub sub: String, // User ID
pub roles: Vec<String>, // Roles
pub permissions: Vec<String>, // Permissions
pub exp: usize, // Expiration time
pub iat: usize, // Issued time
pub tenant_id: String, // Tenant ID
}
/// JWT configuration
pub struct JwtConfig {
pub secret: Vec<u8>, // HS256 key
pub private_key: Vec<u8>, // RS256 private key
pub public_key: Vec<u8>, // RS256 public key
pub algorithm: Algorithm,
pub expiry_secs: u64,
}
#[derive(Debug, Clone, Copy)]
pub enum Algorithm {
HS256,
RS256,
}
/// Issue JWT
pub fn issue_token(config: &JwtConfig, user: &User) -> EnerOSResult<String> {
let now = chrono::Utc::now();
let claims = AuthClaims {
sub: user.id.clone(),
roles: user.roles.clone(),
permissions: user.permissions.clone(),
exp: (now + chrono::Duration::seconds(config.expiry_secs as i64)).timestamp() as usize,
iat: now.timestamp() as usize,
tenant_id: user.tenant_id.clone(),
};
let header = Header::new(match config.algorithm {
Algorithm::HS256 => jsonwebtoken::Algorithm::HS256,
Algorithm::RS256 => jsonwebtoken::Algorithm::RS256,
});
let key = match config.algorithm {
Algorithm::HS256 => EncodingKey::from_secret(&config.secret),
Algorithm::RS256 => EncodingKey::from_rsa_pem(&config.private_key)?,
};
Ok(encode(&header, &claims, &key)?)
}
/// Verify JWT
pub fn verify_token(config: &JwtConfig, token: &str) -> EnerOSResult<AuthClaims> {
let key = match config.algorithm {
Algorithm::HS256 => DecodingKey::from_secret(&config.secret),
Algorithm::RS256 => DecodingKey::from_rsa_pem(&config.public_key)?,
};
let mut validation = Validation::new(match config.algorithm {
Algorithm::HS256 => jsonwebtoken::Algorithm::HS256,
Algorithm::RS256 => jsonwebtoken::Algorithm::RS256,
});
validation.validate_exp = true;
let data = decode::<AuthClaims>(token, &key, &validation)?;
Ok(data.claims)
}
/// Authentication middleware
pub async fn auth_middleware(mut req: Request, next: Next) -> Result<Response, ApiError> {
// Skip health checks
if req.uri().path() == "/health" || req.uri().path() == "/ready" {
return Ok(next.run(req).await);
}
let auth_header = req.headers()
.get(AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|s| s.strip_prefix("Bearer "))
.ok_or(ApiError::Unauthorized)?;
let claims = verify_token(&req.state().jwt_config, auth_header)?;
req.extensions_mut().insert(claims);
Ok(next.run(req).await)
}
4. API Version Management
// crates/eneros-api/src/versioning.rs
use axum::extract::Request;
use axum::middleware::Next;
use axum::response::Response;
/// API version
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ApiVersion(pub u16, pub u16);
/// Version negotiation middleware
pub async fn version_negotiation(mut req: Request, next: Next) -> Result<Response, ApiError> {
// Extract version from URL path (/api/v1/...)
let version = req.uri().path()
.split('/')
.find(|s| s.starts_with('v'))
.and_then(|s| s[1..].parse::<u16>().ok())
.map(|major| ApiVersion(major, 0))
.unwrap_or(ApiVersion(1, 0));
if version.0 > MAX_API_VERSION.0 {
return Err(ApiError::UnsupportedVersion(version));
}
req.extensions_mut().insert(version);
Ok(next.run(req).await)
}
/// Versioned router
pub fn versioned_router(state: Arc<AppState>) -> Router {
Router::new()
.nest("/api/v1", v1_routes(state.clone()))
.nest("/api/v2", v2_routes(state))
}
Improvements
eneros-agent: Agents can be created and managed via APIeneros-timeseries: Added batch write interface- Dependencies: Introduced
axum,utoipa,jsonwebtoken,tower-http - CI: Added API integration tests (based on
reqwest)
Bug Fixes
- Fixed
list_busesreturning 500 instead of empty array when network is empty (#145) - Fixed
auth_middlewarepanicking when Authorization header is missing (#149) - Fixed missing fields in
BusResponseschema in OpenAPI document (#153) - Fixed returning 500 instead of 401 after JWT expiration (#157)
Breaking Changes
- No breaking changes: API layer is a new crate, does not affect existing interfaces
Performance Improvements
| Endpoint | Method | P50 | P99 | Throughput |
|---|---|---|---|---|
| /health | GET | 0.3 ms | 0.8 ms | 180,000/sec |
| /topology/buses | GET | 1.1 ms | 2.4 ms | 42,000/sec |
| /powerflow/solve | POST | 2.8 ms | 8.5 ms | 8,500/sec |
| /timeseries/query | POST | 4.5 ms | 12 ms | 5,500/sec |
| /agents (create) | POST | 2.2 ms | 5.1 ms | 12,000/sec |
Concurrency performance (4 cores 8GB):
| Concurrent Connections | QPS | CPU | Memory |
|---|---|---|---|
| 100 | 42,000 | 45% | 120 MB |
| 500 | 85,000 | 78% | 280 MB |
| 1000 | 98,000 | 92% | 420 MB |
| 2000 | 99,000 | 98% | 580 MB |
API endpoint category statistics:
| Resource | Endpoints | Description |
|---|---|---|
| Topology | 8 | Bus/Branch/Generator/Electrical island |
| Power flow | 4 | Solve/Result/History |
| Constraints | 4 | List/Check/Alert |
| Agent | 10 | CRUD + Messages |
| Time series | 6 | Query/Write/Downsample |
| System | 10 | Auth/Health/Version |
| Total | 42 | - |
Contributors
| Contributor | Role | Commits |
|---|---|---|
| @eneros-foundation | Architect | 22 |
| @axum-expert | Web Framework Expert | 48 |
| @api-designer | API Design | 35 |
| @security-engineer | Security Engineer | 41 |
| @openapi-contrib | Documentation Automation | 18 |
Upgrade Guide
New Dependencies
[dependencies]
eneros-api = { version = "0.10", path = "../eneros-api" }
axum = "0.7"
tokio = { version = "1.35", features = ["full"] }
tower = "0.4"
tower-http = { version = "0.5", features = ["trace", "cors"] }
utoipa = { version = "4", features = ["axum_extras"] }
utoipa-swagger-ui = { version = "5", features = ["axum"] }
jsonwebtoken = "9"
Start API Service
use eneros_api::{ApiApp, AppState};
use std::sync::Arc;
#[tokio::main]
async fn main() -> EnerOSResult<()> {
let state = Arc::new(AppState::new(/* ... */));
let app = ApiApp::new(state);
println!("EnerOS API service started at http://0.0.0.0:8080");
println!("Swagger UI: http://localhost:8080/docs");
app.serve("0.0.0.0:8080").await?;
Ok(())
}
Get JWT Token
# Login to get token
curl -X POST http://localhost:8080/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"username": "admin", "password": "..."}'
# Response
# {"token": "eyJ...", "expires_in": 3600}
Call API
# Query bus list
curl http://localhost:8080/api/v1/topology/buses \
-H "Authorization: Bearer eyJ..."
# Trigger power flow calculation
curl -X POST http://localhost:8080/api/v1/powerflow/solve \
-H "Authorization: Bearer eyJ..." \
-H "Content-Type: application/json" \
-d '{"method": "newton_raphson", "tolerance": 1e-8}'
# Query time series data
curl -X POST http://localhost:8080/api/v1/timeseries/query \
-H "Authorization: Bearer eyJ..." \
-H "Content-Type: application/json" \
-d '{
"measurement_id": 1001,
"start_ts": "2024-10-01T00:00:00Z",
"end_ts": "2024-10-01T01:00:00Z"
}'
API Version Management Strategy
EnerOS API follows semantic versioning: v1 in the URL path corresponds to the major version number. Backward-compatible changes (adding fields, adding endpoints) do not increment the version number; breaking changes require adding a v2 path and keeping v1 for at least 12 months. Clients should be aware of deprecation notices through the X-EnerOS-Deprecation response header.