Files
ruvnet--RuView/v2/crates/homecore-api/src/rest.rs
T
ruv 02742f2f47 feat(homecore/p1 iter-2): API (ADR-130) + plugins (ADR-128) scaffolds in parallel
Two new crates land in this iteration of the HOMECORE swarm:

## v2/crates/homecore-api/  (ADR-130 P1, sequential foundation)

Wire-compat Axum REST + WebSocket port of HA's API. P2-tier subset:

REST routes:
- GET  /api/                           — health ping (HA parity)
- GET  /api/config                     — bare HOMECORE config
- GET  /api/states                     — all entity states
- GET  /api/states/{entity_id}         — one state (404 if missing)
- POST /api/states/{entity_id}         — set state, fire state_changed
- GET  /api/services                   — services grouped by domain
- POST /api/services/{domain}/{service} — call service

WebSocket (/api/websocket):
- auth_required → auth → auth_ok handshake (P1 accepts any non-empty
  bearer; P2 wires the token store)
- get_states, get_config, get_services, call_service
- subscribe_events (per-event-type filter, broadcasts state_changed +
  domain events with HA's event-envelope shape)
- unsubscribe_events
- ping/pong

`homecore-api-server` binary boots a HomeCore on :8123, ready for a
curl smoke test against the wire format.

## v2/crates/homecore-plugins/  (ADR-128 P1, concurrent foundation)

Plugin runtime scaffold per ADR-128:
- PluginManifest mirrors HA manifest.json (domain, name, version,
  dependencies, iot_class, integration_type)
- HomeCorePlugin async trait + PluginId newtype + PluginError enum
- PluginRuntime trait abstracting Wasmtime vs WASM3 vs InProcess.
  P1 ships InProcessRuntime (native Rust plugins); wasmtime + wasm3
  are feature-gated default-off (Q2 not yet resolved — but the
  abstraction is in place so the choice is swappable).
- PluginRegistry: load/unload/list by PluginId.

## Test summary

- homecore:        20/20 (state machine, event bus, services, registry)
- homecore-api:     4/4 (BearerAuth header parsing)
- homecore-plugins:10/10 (manifest, registry, runtime, error variants)
- Total:           34/34 passing

## Coordination state

swarm-memory-manager namespace `homecore-impl/*`:
- iteration: iter-2 
- adr-127/phase: P1-complete 
- adr-130/phase: P1-scaffold-in-progress (now P1-complete)
- adr-128/phase: P1-scaffold-in-progress (now P1-complete)

## Critical path advanced

ADR-127  → ADR-130  → ADR-128  — the unblocking foundation
is now done. Next iteration can fan out 129/131/132/133/134/125
concurrently. Tracking issue #798.

Refs: docs/adr/ADR-130-homecore-rest-websocket-api.md
Refs: docs/adr/ADR-128-homecore-integration-plugin-system.md
Refs: #798

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-25 18:14:14 -04:00

148 lines
4.9 KiB
Rust

use axum::extract::{Path, State};
use axum::http::{HeaderMap, StatusCode};
use axum::Json;
use serde::{Deserialize, Serialize};
use homecore::{Context, EntityId};
use crate::auth::BearerAuth;
use crate::error::{ApiError, ApiResult};
use crate::state::SharedState;
#[derive(Serialize)]
pub struct ApiRunning { message: &'static str }
pub async fn api_root() -> Json<ApiRunning> {
Json(ApiRunning { message: "API running." })
}
#[derive(Serialize)]
pub struct ApiConfig {
location_name: String,
version: String,
state: &'static str,
components: Vec<String>,
}
pub async fn get_config(headers: HeaderMap, State(s): State<SharedState>) -> ApiResult<Json<ApiConfig>> {
let _ = BearerAuth::from_headers(&headers)?;
Ok(Json(ApiConfig {
location_name: s.location_name().to_string(),
version: s.version().to_string(),
state: "RUNNING",
components: vec![],
}))
}
#[derive(Serialize)]
pub struct StateView {
pub entity_id: String,
pub state: String,
pub attributes: serde_json::Value,
pub last_changed: String,
pub last_updated: String,
pub context: ContextView,
}
#[derive(Serialize)]
pub struct ContextView {
pub id: String,
pub user_id: Option<String>,
pub parent_id: Option<String>,
}
impl StateView {
pub fn from_state(s: &homecore::State) -> Self {
Self {
entity_id: s.entity_id.as_str().to_string(),
state: s.state.clone(),
attributes: s.attributes.clone(),
last_changed: s.last_changed.to_rfc3339(),
last_updated: s.last_updated.to_rfc3339(),
context: ContextView {
id: s.context.id.to_string(),
user_id: s.context.user_id.clone(),
parent_id: s.context.parent_id.map(|p| p.to_string()),
},
}
}
}
pub async fn get_states(headers: HeaderMap, State(s): State<SharedState>) -> ApiResult<Json<Vec<StateView>>> {
let _ = BearerAuth::from_headers(&headers)?;
let snapshots = s.homecore().states().all();
Ok(Json(snapshots.iter().map(|x| StateView::from_state(x)).collect()))
}
pub async fn get_state(
headers: HeaderMap,
State(s): State<SharedState>,
Path(entity_id): Path<String>,
) -> ApiResult<Json<StateView>> {
let _ = BearerAuth::from_headers(&headers)?;
let id = EntityId::parse(entity_id.clone()).map_err(|e| ApiError::BadRequest(e.to_string()))?;
let st = s.homecore().states().get(&id).ok_or_else(|| ApiError::NotFound(entity_id))?;
Ok(Json(StateView::from_state(&st)))
}
#[derive(Deserialize)]
pub struct SetStateRequest {
pub state: String,
#[serde(default)]
pub attributes: serde_json::Value,
}
pub async fn set_state(
headers: HeaderMap,
State(s): State<SharedState>,
Path(entity_id): Path<String>,
Json(body): Json<SetStateRequest>,
) -> ApiResult<(StatusCode, Json<StateView>)> {
let _ = BearerAuth::from_headers(&headers)?;
let id = EntityId::parse(entity_id).map_err(|e| ApiError::BadRequest(e.to_string()))?;
let existed = s.homecore().states().get(&id).is_some();
let attrs = if body.attributes.is_null() { serde_json::json!({}) } else { body.attributes };
let snap = s.homecore().states().set(id, body.state, attrs, Context::new());
let status = if existed { StatusCode::OK } else { StatusCode::CREATED };
Ok((status, Json(StateView::from_state(&snap))))
}
#[derive(Serialize)]
pub struct ServiceDomainView {
pub domain: String,
pub services: serde_json::Value,
}
pub async fn get_services(headers: HeaderMap, State(s): State<SharedState>) -> ApiResult<Json<Vec<ServiceDomainView>>> {
let _ = BearerAuth::from_headers(&headers)?;
let services = s.homecore().services().registered_services().await;
let mut by_domain: std::collections::HashMap<String, serde_json::Map<String, serde_json::Value>> =
std::collections::HashMap::new();
for sv in services {
by_domain.entry(sv.domain.clone()).or_default().insert(sv.service.clone(), serde_json::json!({}));
}
Ok(Json(by_domain.into_iter().map(|(domain, services)| ServiceDomainView {
domain, services: serde_json::Value::Object(services),
}).collect()))
}
pub async fn call_service(
headers: HeaderMap,
State(s): State<SharedState>,
Path((domain, service)): Path<(String, String)>,
Json(body): Json<serde_json::Value>,
) -> ApiResult<Json<serde_json::Value>> {
use homecore::{ServiceCall, ServiceName};
let _ = BearerAuth::from_headers(&headers)?;
let call = ServiceCall {
name: ServiceName::new(domain.clone(), service.clone()),
data: body,
context: Context::new(),
};
let resp = s.homecore().services().call(call).await.map_err(|e| match e {
homecore::ServiceError::NotRegistered { .. } => ApiError::ServiceNotRegistered { domain, service },
other => ApiError::Internal(other.to_string()),
})?;
Ok(Json(resp))
}