add config-sqlite and http-api adapters

SQLite config store: full ConfigRepository impl with JSON serialization
for mappings, layouts, data source configs. 12 integration tests.

HTTP API: Axum REST endpoints for widgets, data sources, layout, presets.
6 integration tests using tower::oneshot.

Port traits updated to return Send futures for Axum compatibility.
This commit is contained in:
2026-06-18 22:47:38 +02:00
parent 3ee6a5d215
commit e398c240a0
16 changed files with 3284 additions and 50 deletions

View File

@@ -0,0 +1,19 @@
[package]
name = "http-api"
version = "0.1.0"
edition = "2024"
[dependencies]
domain.workspace = true
application.workspace = true
axum.workspace = true
tower-http.workspace = true
serde.workspace = true
serde_json.workspace = true
[dev-dependencies]
tokio.workspace = true
tower.workspace = true
serde_json.workspace = true
config-memory.workspace = true
tcp-server.workspace = true

View File

@@ -0,0 +1,285 @@
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize)]
pub struct KeyMappingDto {
pub source_path: String,
pub target_key: String,
}
#[derive(Serialize, Deserialize)]
pub struct WidgetDto {
pub id: u16,
pub name: String,
pub display_hint: String,
pub data_source_id: u16,
pub mappings: Vec<KeyMappingDto>,
pub max_data_size: u16,
}
#[derive(Serialize, Deserialize)]
pub struct CreateWidgetDto {
pub id: u16,
pub name: String,
pub display_hint: String,
pub data_source_id: u16,
pub mappings: Vec<KeyMappingDto>,
#[serde(default = "default_max_data_size")]
pub max_data_size: u16,
}
fn default_max_data_size() -> u16 { 2048 }
#[derive(Serialize, Deserialize)]
pub struct DataSourceDto {
pub id: u16,
pub name: String,
pub source_type: String,
pub poll_interval_secs: u64,
pub url: Option<String>,
pub api_key: Option<String>,
pub headers: Vec<(String, String)>,
}
#[derive(Serialize, Deserialize)]
pub struct SizingDto {
#[serde(rename = "type")]
pub sizing_type: String,
pub value: u16,
}
#[derive(Serialize, Deserialize)]
pub struct LayoutNodeDto {
#[serde(rename = "type")]
pub node_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub widget_id: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub direction: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gap: Option<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
pub padding: Option<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
pub children: Option<Vec<LayoutChildDto>>,
}
#[derive(Serialize, Deserialize)]
pub struct LayoutChildDto {
pub sizing: SizingDto,
pub node: LayoutNodeDto,
}
#[derive(Serialize, Deserialize)]
pub struct LayoutDto {
pub root: LayoutNodeDto,
}
#[derive(Serialize, Deserialize)]
pub struct PresetDto {
pub id: u16,
pub name: String,
pub layout: LayoutDto,
}
#[derive(Serialize, Deserialize)]
pub struct CreatePresetDto {
pub id: u16,
pub name: String,
pub layout: LayoutDto,
}
use domain::*;
use std::time::Duration;
impl From<&WidgetConfig> for WidgetDto {
fn from(w: &WidgetConfig) -> Self {
Self {
id: w.id,
name: w.name.clone(),
display_hint: match w.display_hint {
DisplayHint::IconValue => "icon_value",
DisplayHint::TextBlock => "text_block",
DisplayHint::KeyValue => "key_value",
}.into(),
data_source_id: w.data_source_id,
mappings: w.mappings.iter().map(|m| KeyMappingDto {
source_path: m.source_path.clone(),
target_key: m.target_key.clone(),
}).collect(),
max_data_size: w.max_data_size,
}
}
}
impl CreateWidgetDto {
pub fn into_domain(self) -> Result<WidgetConfig, String> {
let hint = match self.display_hint.as_str() {
"icon_value" => DisplayHint::IconValue,
"text_block" => DisplayHint::TextBlock,
"key_value" => DisplayHint::KeyValue,
h => return Err(format!("unknown display_hint: {h}")),
};
Ok(WidgetConfig {
id: self.id,
name: self.name,
display_hint: hint,
data_source_id: self.data_source_id,
mappings: self.mappings.into_iter().map(|m| KeyMapping {
source_path: m.source_path,
target_key: m.target_key,
}).collect(),
max_data_size: self.max_data_size,
})
}
}
impl From<&DataSource> for DataSourceDto {
fn from(ds: &DataSource) -> Self {
Self {
id: ds.id,
name: ds.name.clone(),
source_type: match ds.source_type {
DataSourceType::Weather => "weather",
DataSourceType::Media => "media",
DataSourceType::Xtb => "xtb",
DataSourceType::Rss => "rss",
DataSourceType::HttpJson => "http_json",
DataSourceType::Webhook => "webhook",
}.into(),
poll_interval_secs: ds.poll_interval.as_secs(),
url: ds.config.url.clone(),
api_key: ds.config.api_key.clone(),
headers: ds.config.headers.clone(),
}
}
}
impl DataSourceDto {
pub fn into_domain(self) -> Result<DataSource, String> {
let source_type = match self.source_type.as_str() {
"weather" => DataSourceType::Weather,
"media" => DataSourceType::Media,
"xtb" => DataSourceType::Xtb,
"rss" => DataSourceType::Rss,
"http_json" => DataSourceType::HttpJson,
"webhook" => DataSourceType::Webhook,
t => return Err(format!("unknown source_type: {t}")),
};
Ok(DataSource {
id: self.id,
name: self.name,
source_type,
poll_interval: Duration::from_secs(self.poll_interval_secs),
config: DataSourceConfig {
url: self.url,
api_key: self.api_key,
headers: self.headers,
},
})
}
}
impl From<&LayoutNode> for LayoutNodeDto {
fn from(node: &LayoutNode) -> Self {
match node {
LayoutNode::Leaf(id) => Self {
node_type: "leaf".into(),
widget_id: Some(*id),
direction: None, gap: None, padding: None, children: None,
},
LayoutNode::Container(c) => Self {
node_type: "container".into(),
widget_id: None,
direction: Some(match c.direction {
Direction::Row => "row",
Direction::Column => "column",
}.into()),
gap: Some(c.gap),
padding: Some(c.padding),
children: Some(c.children.iter().map(|ch| LayoutChildDto {
sizing: SizingDto {
sizing_type: match ch.sizing {
Sizing::Fixed(_) => "fixed".into(),
Sizing::Flex(_) => "flex".into(),
},
value: match ch.sizing {
Sizing::Fixed(v) => v,
Sizing::Flex(v) => v as u16,
},
},
node: (&ch.node).into(),
}).collect()),
},
}
}
}
impl LayoutNodeDto {
pub fn into_domain(self) -> Result<LayoutNode, String> {
match self.node_type.as_str() {
"leaf" => {
let id = self.widget_id.ok_or("missing widget_id")?;
Ok(LayoutNode::Leaf(id))
}
"container" => {
let direction = match self.direction.as_deref().ok_or("missing direction")? {
"row" => Direction::Row,
"column" => Direction::Column,
d => return Err(format!("unknown direction: {d}")),
};
let children = self.children.ok_or("missing children")?
.into_iter()
.map(|ch| {
let sizing = match ch.sizing.sizing_type.as_str() {
"fixed" => Sizing::Fixed(ch.sizing.value),
"flex" => Sizing::Flex(ch.sizing.value as u8),
s => return Err(format!("unknown sizing: {s}")),
};
let node = ch.node.into_domain()?;
Ok(LayoutChild { sizing, node })
})
.collect::<Result<Vec<_>, _>>()?;
Ok(LayoutNode::Container(ContainerNode {
direction,
gap: self.gap.unwrap_or(0),
padding: self.padding.unwrap_or(0),
children,
}))
}
t => Err(format!("unknown node type: {t}")),
}
}
}
impl From<&Layout> for LayoutDto {
fn from(l: &Layout) -> Self {
Self { root: (&l.root).into() }
}
}
impl LayoutDto {
pub fn into_domain(self) -> Result<Layout, String> {
Ok(Layout { root: self.root.into_domain()? })
}
}
impl From<&LayoutPreset> for PresetDto {
fn from(p: &LayoutPreset) -> Self {
Self {
id: p.id,
name: p.name.clone(),
layout: (&p.layout).into(),
}
}
}
impl CreatePresetDto {
pub fn into_domain(self) -> Result<LayoutPreset, String> {
Ok(LayoutPreset {
id: self.id,
name: self.name,
layout: self.layout.into_domain()?,
})
}
}

View File

@@ -0,0 +1,34 @@
mod dto;
mod routes;
use std::sync::Arc;
use axum::Router;
use tower_http::cors::CorsLayer;
use domain::{ConfigRepository, EventPublisher};
pub struct AppState<C, E> {
pub config: Arc<C>,
pub events: Arc<E>,
}
impl<C, E> Clone for AppState<C, E> {
fn clone(&self) -> Self {
Self {
config: self.config.clone(),
events: self.events.clone(),
}
}
}
pub fn router<C, E>(state: AppState<C, E>) -> Router
where
C: ConfigRepository + Send + Sync + 'static,
C::Error: std::fmt::Debug + Send,
E: EventPublisher + Send + Sync + 'static,
E::Error: std::fmt::Debug + Send,
{
Router::new()
.nest("/api", routes::api_routes())
.layer(CorsLayer::permissive())
.with_state(state)
}

View File

@@ -0,0 +1,176 @@
use std::sync::Arc;
use axum::{
Router,
extract::{Path, State},
http::StatusCode,
response::Json,
routing::{get, post, put, delete},
};
use domain::{ConfigRepository, EventPublisher};
use application::ConfigService;
use crate::AppState;
use crate::dto::*;
type S<C, E> = State<AppState<C, E>>;
pub fn api_routes<C, E>() -> Router<AppState<C, E>>
where
C: ConfigRepository + Send + Sync + 'static,
C::Error: std::fmt::Debug + Send,
E: EventPublisher + Send + Sync + 'static,
E::Error: std::fmt::Debug + Send,
{
Router::new()
.route("/widgets", get(list_widgets::<C, E>).post(create_widget::<C, E>))
.route("/widgets/{id}", get(get_widget::<C, E>).put(update_widget::<C, E>).delete(delete_widget::<C, E>))
.route("/data-sources", get(list_data_sources::<C, E>).post(create_data_source::<C, E>))
.route("/data-sources/{id}", get(get_data_source::<C, E>).put(update_data_source::<C, E>).delete(delete_data_source::<C, E>))
.route("/layout", get(get_layout::<C, E>).put(update_layout::<C, E>))
.route("/presets", get(list_presets::<C, E>).post(create_preset::<C, E>))
.route("/presets/{id}", get(get_preset::<C, E>).delete(delete_preset::<C, E>))
.route("/presets/{id}/load", post(load_preset::<C, E>))
}
async fn list_widgets<C, E>(State(state): S<C, E>) -> Result<Json<Vec<WidgetDto>>, StatusCode>
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug,
{
let widgets = state.config.list_widgets().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(widgets.iter().map(WidgetDto::from).collect()))
}
async fn get_widget<C, E>(State(state): S<C, E>, Path(id): Path<u16>) -> Result<Json<WidgetDto>, StatusCode>
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug,
{
let widget = state.config.get_widget(id).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
match widget {
Some(w) => Ok(Json(WidgetDto::from(&w))),
None => Err(StatusCode::NOT_FOUND),
}
}
async fn create_widget<C, E>(State(state): S<C, E>, Json(body): Json<CreateWidgetDto>) -> Result<StatusCode, (StatusCode, String)>
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug,
{
let widget = body.into_domain().map_err(|e| (StatusCode::BAD_REQUEST, e))?;
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
svc.create_widget(widget).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
Ok(StatusCode::CREATED)
}
async fn update_widget<C, E>(State(state): S<C, E>, Path(_id): Path<u16>, Json(body): Json<CreateWidgetDto>) -> Result<StatusCode, (StatusCode, String)>
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug,
{
let widget = body.into_domain().map_err(|e| (StatusCode::BAD_REQUEST, e))?;
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
svc.update_widget(widget).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
Ok(StatusCode::OK)
}
async fn delete_widget<C, E>(State(state): S<C, E>, Path(id): Path<u16>) -> Result<StatusCode, StatusCode>
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug,
{
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
svc.delete_widget(id).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(StatusCode::NO_CONTENT)
}
async fn list_data_sources<C, E>(State(state): S<C, E>) -> Result<Json<Vec<DataSourceDto>>, StatusCode>
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug,
{
let sources = state.config.list_data_sources().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(sources.iter().map(DataSourceDto::from).collect()))
}
async fn get_data_source<C, E>(State(state): S<C, E>, Path(id): Path<u16>) -> Result<Json<DataSourceDto>, StatusCode>
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug,
{
let source = state.config.get_data_source(id).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
match source {
Some(s) => Ok(Json(DataSourceDto::from(&s))),
None => Err(StatusCode::NOT_FOUND),
}
}
async fn create_data_source<C, E>(State(state): S<C, E>, Json(body): Json<DataSourceDto>) -> Result<StatusCode, (StatusCode, String)>
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug,
{
let source = body.into_domain().map_err(|e| (StatusCode::BAD_REQUEST, e))?;
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
svc.create_data_source(source).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
Ok(StatusCode::CREATED)
}
async fn update_data_source<C, E>(State(state): S<C, E>, Path(_id): Path<u16>, Json(body): Json<DataSourceDto>) -> Result<StatusCode, (StatusCode, String)>
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug,
{
let source = body.into_domain().map_err(|e| (StatusCode::BAD_REQUEST, e))?;
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
svc.update_data_source(source).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
Ok(StatusCode::OK)
}
async fn delete_data_source<C, E>(State(state): S<C, E>, Path(id): Path<u16>) -> Result<StatusCode, StatusCode>
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug,
{
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
svc.delete_data_source(id).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(StatusCode::NO_CONTENT)
}
async fn get_layout<C, E>(State(state): S<C, E>) -> Result<Json<Option<LayoutDto>>, StatusCode>
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug,
{
let layout = state.config.get_layout().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(layout.as_ref().map(LayoutDto::from)))
}
async fn update_layout<C, E>(State(state): S<C, E>, Json(body): Json<LayoutDto>) -> Result<StatusCode, (StatusCode, String)>
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug,
{
let layout = body.into_domain().map_err(|e| (StatusCode::BAD_REQUEST, e))?;
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
svc.update_layout(layout).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
Ok(StatusCode::OK)
}
async fn list_presets<C, E>(State(state): S<C, E>) -> Result<Json<Vec<PresetDto>>, StatusCode>
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug,
{
let presets = state.config.list_presets().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(presets.iter().map(PresetDto::from).collect()))
}
async fn get_preset<C, E>(State(state): S<C, E>, Path(id): Path<u16>) -> Result<Json<PresetDto>, StatusCode>
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug,
{
let preset = state.config.get_preset(id).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
match preset {
Some(p) => Ok(Json(PresetDto::from(&p))),
None => Err(StatusCode::NOT_FOUND),
}
}
async fn create_preset<C, E>(State(state): S<C, E>, Json(body): Json<CreatePresetDto>) -> Result<StatusCode, (StatusCode, String)>
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug,
{
let preset = body.into_domain().map_err(|e| (StatusCode::BAD_REQUEST, e))?;
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
svc.save_preset(preset).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
Ok(StatusCode::CREATED)
}
async fn delete_preset<C, E>(State(state): S<C, E>, Path(id): Path<u16>) -> Result<StatusCode, StatusCode>
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug,
{
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
svc.delete_preset(id).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(StatusCode::NO_CONTENT)
}
async fn load_preset<C, E>(State(state): S<C, E>, Path(id): Path<u16>) -> Result<StatusCode, (StatusCode, String)>
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug,
{
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
svc.load_preset(id).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
Ok(StatusCode::OK)
}

View File

@@ -0,0 +1,149 @@
use std::sync::Arc;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use tower::ServiceExt;
use config_memory::MemoryConfigStore;
use tcp_server::TcpEventBus;
use http_api::{AppState, router};
fn test_app() -> axum::Router {
let config = Arc::new(MemoryConfigStore::new());
let events = Arc::new(TcpEventBus::new(16));
let state = AppState { config, events };
router(state)
}
fn json_request(method: &str, uri: &str, body: Option<&str>) -> Request<Body> {
let mut builder = Request::builder()
.method(method)
.uri(uri)
.header("content-type", "application/json");
if let Some(b) = body {
builder.body(Body::from(b.to_string())).unwrap()
} else {
builder.body(Body::empty()).unwrap()
}
}
#[tokio::test]
async fn create_and_get_widget() {
let app = test_app();
let body = r#"{
"id": 1,
"name": "weather",
"display_hint": "icon_value",
"data_source_id": 10,
"mappings": [{"source_path": "$.temp", "target_key": "temperature"}]
}"#;
let resp = app.clone().oneshot(json_request("POST", "/api/widgets", Some(body))).await.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let resp = app.oneshot(json_request("GET", "/api/widgets/1", None)).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["name"], "weather");
assert_eq!(json["display_hint"], "icon_value");
assert_eq!(json["data_source_id"], 10);
}
#[tokio::test]
async fn list_widgets() {
let app = test_app();
let w1 = r#"{"id":1,"name":"a","display_hint":"icon_value","data_source_id":1,"mappings":[]}"#;
let w2 = r#"{"id":2,"name":"b","display_hint":"key_value","data_source_id":2,"mappings":[]}"#;
app.clone().oneshot(json_request("POST", "/api/widgets", Some(w1))).await.unwrap();
app.clone().oneshot(json_request("POST", "/api/widgets", Some(w2))).await.unwrap();
let resp = app.oneshot(json_request("GET", "/api/widgets", None)).await.unwrap();
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let json: Vec<serde_json::Value> = serde_json::from_slice(&body).unwrap();
assert_eq!(json.len(), 2);
}
#[tokio::test]
async fn delete_widget() {
let app = test_app();
let body = r#"{"id":1,"name":"a","display_hint":"icon_value","data_source_id":1,"mappings":[]}"#;
app.clone().oneshot(json_request("POST", "/api/widgets", Some(body))).await.unwrap();
let resp = app.clone().oneshot(json_request("DELETE", "/api/widgets/1", None)).await.unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
let resp = app.oneshot(json_request("GET", "/api/widgets/1", None)).await.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn create_and_get_data_source() {
let app = test_app();
let body = r#"{
"id": 10,
"name": "weather_api",
"source_type": "weather",
"poll_interval_secs": 300,
"url": "https://api.openweather.org",
"api_key": "test-key",
"headers": []
}"#;
let resp = app.clone().oneshot(json_request("POST", "/api/data-sources", Some(body))).await.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let resp = app.oneshot(json_request("GET", "/api/data-sources/10", None)).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["name"], "weather_api");
assert_eq!(json["poll_interval_secs"], 300);
}
#[tokio::test]
async fn update_and_get_layout() {
let app = test_app();
let body = r#"{
"root": {
"type": "container",
"direction": "row",
"gap": 4,
"padding": 2,
"children": [
{"sizing": {"type": "flex", "value": 1}, "node": {"type": "leaf", "widget_id": 1}},
{"sizing": {"type": "fixed", "value": 80}, "node": {"type": "leaf", "widget_id": 2}}
]
}
}"#;
let resp = app.clone().oneshot(json_request("PUT", "/api/layout", Some(body))).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let resp = app.oneshot(json_request("GET", "/api/layout", None)).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["root"]["type"], "container");
assert_eq!(json["root"]["direction"], "row");
assert_eq!(json["root"]["children"].as_array().unwrap().len(), 2);
}
#[tokio::test]
async fn get_nonexistent_returns_404() {
let app = test_app();
let resp = app.clone().oneshot(json_request("GET", "/api/widgets/99", None)).await.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
let resp = app.oneshot(json_request("GET", "/api/data-sources/99", None)).await.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}