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:
1866
Cargo.lock
generated
1866
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,8 @@ members = [
|
|||||||
"crates/adapters/tcp-server",
|
"crates/adapters/tcp-server",
|
||||||
"crates/adapters/tcp-client",
|
"crates/adapters/tcp-client",
|
||||||
"crates/adapters/display-terminal",
|
"crates/adapters/display-terminal",
|
||||||
|
"crates/adapters/config-sqlite",
|
||||||
|
"crates/adapters/http-api",
|
||||||
"crates/bootstrap",
|
"crates/bootstrap",
|
||||||
"crates/client-desktop",
|
"crates/client-desktop",
|
||||||
]
|
]
|
||||||
@@ -27,6 +29,13 @@ config-memory = { path = "crates/adapters/config-memory" }
|
|||||||
tcp-server = { path = "crates/adapters/tcp-server" }
|
tcp-server = { path = "crates/adapters/tcp-server" }
|
||||||
tcp-client = { path = "crates/adapters/tcp-client" }
|
tcp-client = { path = "crates/adapters/tcp-client" }
|
||||||
display-terminal = { path = "crates/adapters/display-terminal" }
|
display-terminal = { path = "crates/adapters/display-terminal" }
|
||||||
|
config-sqlite = { path = "crates/adapters/config-sqlite" }
|
||||||
|
http-api = { path = "crates/adapters/http-api" }
|
||||||
|
axum = { version = "0.8", features = ["macros"] }
|
||||||
|
tower-http = { version = "0.6", features = ["cors"] }
|
||||||
serde = { version = "1.0", default-features = false, features = ["derive", "alloc"] }
|
serde = { version = "1.0", default-features = false, features = ["derive", "alloc"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite"] }
|
||||||
postcard = { version = "1.1", default-features = false, features = ["alloc"] }
|
postcard = { version = "1.1", default-features = false, features = ["alloc"] }
|
||||||
tokio = { version = "1.0", features = ["macros", "rt", "rt-multi-thread", "net", "sync", "time", "io-util"] }
|
tokio = { version = "1.0", features = ["macros", "rt", "rt-multi-thread", "net", "sync", "time", "io-util"] }
|
||||||
|
tower = "0.5"
|
||||||
|
|||||||
13
crates/adapters/config-sqlite/Cargo.toml
Normal file
13
crates/adapters/config-sqlite/Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[package]
|
||||||
|
name = "config-sqlite"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
domain.workspace = true
|
||||||
|
sqlx.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio.workspace = true
|
||||||
262
crates/adapters/config-sqlite/src/lib.rs
Normal file
262
crates/adapters/config-sqlite/src/lib.rs
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
mod serialization;
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
use sqlx::{SqlitePool, Row};
|
||||||
|
use domain::{
|
||||||
|
ConfigRepository,
|
||||||
|
DataSource, DataSourceId, DataSourceConfig, DataSourceType,
|
||||||
|
Layout, LayoutPreset, LayoutPresetId,
|
||||||
|
WidgetConfig, WidgetId,
|
||||||
|
};
|
||||||
|
use serialization as ser;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum SqliteConfigError {
|
||||||
|
Sql(sqlx::Error),
|
||||||
|
Serialization(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for SqliteConfigError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
SqliteConfigError::Sql(e) => write!(f, "sql: {e}"),
|
||||||
|
SqliteConfigError::Serialization(e) => write!(f, "serialization: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SqliteConfigStore {
|
||||||
|
pool: SqlitePool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SqliteConfigStore {
|
||||||
|
pub async fn new(database_url: &str) -> Result<Self, sqlx::Error> {
|
||||||
|
let pool = SqlitePool::connect(database_url).await?;
|
||||||
|
let store = Self { pool };
|
||||||
|
store.migrate().await?;
|
||||||
|
Ok(store)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn migrate(&self) -> Result<(), sqlx::Error> {
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE TABLE IF NOT EXISTS widgets (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
display_hint TEXT NOT NULL,
|
||||||
|
data_source_id INTEGER NOT NULL,
|
||||||
|
mappings TEXT NOT NULL,
|
||||||
|
max_data_size INTEGER NOT NULL
|
||||||
|
)"
|
||||||
|
).execute(&self.pool).await?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE TABLE IF NOT EXISTS data_sources (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
source_type TEXT NOT NULL,
|
||||||
|
poll_interval_secs INTEGER NOT NULL,
|
||||||
|
config TEXT NOT NULL
|
||||||
|
)"
|
||||||
|
).execute(&self.pool).await?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE TABLE IF NOT EXISTS layout (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
data TEXT NOT NULL
|
||||||
|
)"
|
||||||
|
).execute(&self.pool).await?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE TABLE IF NOT EXISTS presets (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
layout_data TEXT NOT NULL
|
||||||
|
)"
|
||||||
|
).execute(&self.pool).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConfigRepository for SqliteConfigStore {
|
||||||
|
type Error = SqliteConfigError;
|
||||||
|
|
||||||
|
async fn get_widget(&self, id: WidgetId) -> Result<Option<WidgetConfig>, Self::Error> {
|
||||||
|
let row = sqlx::query("SELECT * FROM widgets WHERE id = ?")
|
||||||
|
.bind(id as i64)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(SqliteConfigError::Sql)?;
|
||||||
|
|
||||||
|
match row {
|
||||||
|
None => Ok(None),
|
||||||
|
Some(row) => Ok(Some(ser::widget_from_row(&row)?)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_widgets(&self) -> Result<Vec<WidgetConfig>, Self::Error> {
|
||||||
|
let rows = sqlx::query("SELECT * FROM widgets")
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(SqliteConfigError::Sql)?;
|
||||||
|
|
||||||
|
rows.iter().map(|r| ser::widget_from_row(r)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_widget(&self, config: &WidgetConfig) -> Result<(), Self::Error> {
|
||||||
|
let mappings_json = ser::mappings_to_json(&config.mappings)?;
|
||||||
|
let hint_str = ser::display_hint_to_str(&config.display_hint);
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT OR REPLACE INTO widgets (id, name, display_hint, data_source_id, mappings, max_data_size)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)"
|
||||||
|
)
|
||||||
|
.bind(config.id as i64)
|
||||||
|
.bind(&config.name)
|
||||||
|
.bind(hint_str)
|
||||||
|
.bind(config.data_source_id as i64)
|
||||||
|
.bind(&mappings_json)
|
||||||
|
.bind(config.max_data_size as i64)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(SqliteConfigError::Sql)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_widget(&self, id: WidgetId) -> Result<(), Self::Error> {
|
||||||
|
sqlx::query("DELETE FROM widgets WHERE id = ?")
|
||||||
|
.bind(id as i64)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(SqliteConfigError::Sql)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_data_source(&self, id: DataSourceId) -> Result<Option<DataSource>, Self::Error> {
|
||||||
|
let row = sqlx::query("SELECT * FROM data_sources WHERE id = ?")
|
||||||
|
.bind(id as i64)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(SqliteConfigError::Sql)?;
|
||||||
|
|
||||||
|
match row {
|
||||||
|
None => Ok(None),
|
||||||
|
Some(row) => Ok(Some(ser::data_source_from_row(&row)?)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_data_sources(&self) -> Result<Vec<DataSource>, Self::Error> {
|
||||||
|
let rows = sqlx::query("SELECT * FROM data_sources")
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(SqliteConfigError::Sql)?;
|
||||||
|
|
||||||
|
rows.iter().map(|r| ser::data_source_from_row(r)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_data_source(&self, source: &DataSource) -> Result<(), Self::Error> {
|
||||||
|
let config_json = ser::data_source_config_to_json(&source.config)?;
|
||||||
|
let type_str = ser::data_source_type_to_str(&source.source_type);
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT OR REPLACE INTO data_sources (id, name, source_type, poll_interval_secs, config)
|
||||||
|
VALUES (?, ?, ?, ?, ?)"
|
||||||
|
)
|
||||||
|
.bind(source.id as i64)
|
||||||
|
.bind(&source.name)
|
||||||
|
.bind(type_str)
|
||||||
|
.bind(source.poll_interval.as_secs() as i64)
|
||||||
|
.bind(&config_json)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(SqliteConfigError::Sql)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_data_source(&self, id: DataSourceId) -> Result<(), Self::Error> {
|
||||||
|
sqlx::query("DELETE FROM data_sources WHERE id = ?")
|
||||||
|
.bind(id as i64)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(SqliteConfigError::Sql)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_layout(&self) -> Result<Option<Layout>, Self::Error> {
|
||||||
|
let row = sqlx::query("SELECT data FROM layout WHERE id = 1")
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(SqliteConfigError::Sql)?;
|
||||||
|
|
||||||
|
match row {
|
||||||
|
None => Ok(None),
|
||||||
|
Some(row) => {
|
||||||
|
let json: String = row.get("data");
|
||||||
|
Ok(Some(ser::layout_from_json(&json)?))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_layout(&self, layout: &Layout) -> Result<(), Self::Error> {
|
||||||
|
let json = ser::layout_to_json(layout)?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT OR REPLACE INTO layout (id, data) VALUES (1, ?)"
|
||||||
|
)
|
||||||
|
.bind(&json)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(SqliteConfigError::Sql)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_preset(&self, id: LayoutPresetId) -> Result<Option<LayoutPreset>, Self::Error> {
|
||||||
|
let row = sqlx::query("SELECT * FROM presets WHERE id = ?")
|
||||||
|
.bind(id as i64)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(SqliteConfigError::Sql)?;
|
||||||
|
|
||||||
|
match row {
|
||||||
|
None => Ok(None),
|
||||||
|
Some(row) => Ok(Some(ser::preset_from_row(&row)?)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_presets(&self) -> Result<Vec<LayoutPreset>, Self::Error> {
|
||||||
|
let rows = sqlx::query("SELECT * FROM presets")
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(SqliteConfigError::Sql)?;
|
||||||
|
|
||||||
|
rows.iter().map(|r| ser::preset_from_row(r)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_preset(&self, preset: &LayoutPreset) -> Result<(), Self::Error> {
|
||||||
|
let layout_json = ser::layout_to_json(&preset.layout)?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT OR REPLACE INTO presets (id, name, layout_data) VALUES (?, ?, ?)"
|
||||||
|
)
|
||||||
|
.bind(preset.id as i64)
|
||||||
|
.bind(&preset.name)
|
||||||
|
.bind(&layout_json)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(SqliteConfigError::Sql)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_preset(&self, id: LayoutPresetId) -> Result<(), Self::Error> {
|
||||||
|
sqlx::query("DELETE FROM presets WHERE id = ?")
|
||||||
|
.bind(id as i64)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(SqliteConfigError::Sql)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
220
crates/adapters/config-sqlite/src/serialization.rs
Normal file
220
crates/adapters/config-sqlite/src/serialization.rs
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
use sqlx::Row;
|
||||||
|
use sqlx::sqlite::SqliteRow;
|
||||||
|
use domain::{
|
||||||
|
ContainerNode, DataSource, DataSourceConfig, DataSourceType, Direction,
|
||||||
|
DisplayHint, KeyMapping, Layout, LayoutChild, LayoutNode, LayoutPreset,
|
||||||
|
Sizing, WidgetConfig,
|
||||||
|
};
|
||||||
|
use crate::SqliteConfigError;
|
||||||
|
|
||||||
|
pub fn display_hint_to_str(hint: &DisplayHint) -> &'static str {
|
||||||
|
match hint {
|
||||||
|
DisplayHint::IconValue => "icon_value",
|
||||||
|
DisplayHint::TextBlock => "text_block",
|
||||||
|
DisplayHint::KeyValue => "key_value",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn display_hint_from_str(s: &str) -> Result<DisplayHint, SqliteConfigError> {
|
||||||
|
match s {
|
||||||
|
"icon_value" => Ok(DisplayHint::IconValue),
|
||||||
|
"text_block" => Ok(DisplayHint::TextBlock),
|
||||||
|
"key_value" => Ok(DisplayHint::KeyValue),
|
||||||
|
_ => Err(SqliteConfigError::Serialization(format!("unknown display hint: {s}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn data_source_type_to_str(t: &DataSourceType) -> &'static str {
|
||||||
|
match t {
|
||||||
|
DataSourceType::Weather => "weather",
|
||||||
|
DataSourceType::Media => "media",
|
||||||
|
DataSourceType::Xtb => "xtb",
|
||||||
|
DataSourceType::Rss => "rss",
|
||||||
|
DataSourceType::HttpJson => "http_json",
|
||||||
|
DataSourceType::Webhook => "webhook",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn data_source_type_from_str(s: &str) -> Result<DataSourceType, SqliteConfigError> {
|
||||||
|
match s {
|
||||||
|
"weather" => Ok(DataSourceType::Weather),
|
||||||
|
"media" => Ok(DataSourceType::Media),
|
||||||
|
"xtb" => Ok(DataSourceType::Xtb),
|
||||||
|
"rss" => Ok(DataSourceType::Rss),
|
||||||
|
"http_json" => Ok(DataSourceType::HttpJson),
|
||||||
|
"webhook" => Ok(DataSourceType::Webhook),
|
||||||
|
_ => Err(SqliteConfigError::Serialization(format!("unknown source type: {s}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mappings_to_json(mappings: &[KeyMapping]) -> Result<String, SqliteConfigError> {
|
||||||
|
let entries: Vec<serde_json::Value> = mappings.iter().map(|m| {
|
||||||
|
serde_json::json!({
|
||||||
|
"source_path": m.source_path,
|
||||||
|
"target_key": m.target_key,
|
||||||
|
})
|
||||||
|
}).collect();
|
||||||
|
serde_json::to_string(&entries).map_err(|e| SqliteConfigError::Serialization(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mappings_from_json(json: &str) -> Result<Vec<KeyMapping>, SqliteConfigError> {
|
||||||
|
let entries: Vec<serde_json::Value> = serde_json::from_str(json)
|
||||||
|
.map_err(|e| SqliteConfigError::Serialization(e.to_string()))?;
|
||||||
|
|
||||||
|
entries.iter().map(|v| {
|
||||||
|
Ok(KeyMapping {
|
||||||
|
source_path: v["source_path"].as_str()
|
||||||
|
.ok_or_else(|| SqliteConfigError::Serialization("missing source_path".into()))?.into(),
|
||||||
|
target_key: v["target_key"].as_str()
|
||||||
|
.ok_or_else(|| SqliteConfigError::Serialization("missing target_key".into()))?.into(),
|
||||||
|
})
|
||||||
|
}).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn data_source_config_to_json(config: &DataSourceConfig) -> Result<String, SqliteConfigError> {
|
||||||
|
let v = serde_json::json!({
|
||||||
|
"url": config.url,
|
||||||
|
"headers": config.headers,
|
||||||
|
"api_key": config.api_key,
|
||||||
|
});
|
||||||
|
serde_json::to_string(&v).map_err(|e| SqliteConfigError::Serialization(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn data_source_config_from_json(json: &str) -> Result<DataSourceConfig, SqliteConfigError> {
|
||||||
|
let v: serde_json::Value = serde_json::from_str(json)
|
||||||
|
.map_err(|e| SqliteConfigError::Serialization(e.to_string()))?;
|
||||||
|
|
||||||
|
let url = v["url"].as_str().map(String::from);
|
||||||
|
let api_key = v["api_key"].as_str().map(String::from);
|
||||||
|
let headers = match v["headers"].as_array() {
|
||||||
|
Some(arr) => arr.iter().filter_map(|h| {
|
||||||
|
let pair = h.as_array()?;
|
||||||
|
Some((pair[0].as_str()?.into(), pair[1].as_str()?.into()))
|
||||||
|
}).collect(),
|
||||||
|
None => vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(DataSourceConfig { url, headers, api_key })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn layout_to_json(layout: &Layout) -> Result<String, SqliteConfigError> {
|
||||||
|
let v = node_to_json(&layout.root);
|
||||||
|
serde_json::to_string(&v).map_err(|e| SqliteConfigError::Serialization(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn layout_from_json(json: &str) -> Result<Layout, SqliteConfigError> {
|
||||||
|
let v: serde_json::Value = serde_json::from_str(json)
|
||||||
|
.map_err(|e| SqliteConfigError::Serialization(e.to_string()))?;
|
||||||
|
let root = node_from_json(&v)?;
|
||||||
|
Ok(Layout { root })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn node_to_json(node: &LayoutNode) -> serde_json::Value {
|
||||||
|
match node {
|
||||||
|
LayoutNode::Leaf(id) => serde_json::json!({ "type": "leaf", "widget_id": id }),
|
||||||
|
LayoutNode::Container(c) => {
|
||||||
|
let children: Vec<serde_json::Value> = c.children.iter().map(|ch| {
|
||||||
|
let sizing = match &ch.sizing {
|
||||||
|
Sizing::Fixed(px) => serde_json::json!({ "type": "fixed", "value": px }),
|
||||||
|
Sizing::Flex(w) => serde_json::json!({ "type": "flex", "value": w }),
|
||||||
|
};
|
||||||
|
serde_json::json!({
|
||||||
|
"sizing": sizing,
|
||||||
|
"node": node_to_json(&ch.node),
|
||||||
|
})
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
serde_json::json!({
|
||||||
|
"type": "container",
|
||||||
|
"direction": match c.direction { Direction::Row => "row", Direction::Column => "column" },
|
||||||
|
"gap": c.gap,
|
||||||
|
"padding": c.padding,
|
||||||
|
"children": children,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn node_from_json(v: &serde_json::Value) -> Result<LayoutNode, SqliteConfigError> {
|
||||||
|
let err = |msg: &str| SqliteConfigError::Serialization(msg.into());
|
||||||
|
|
||||||
|
match v["type"].as_str().ok_or_else(|| err("missing node type"))? {
|
||||||
|
"leaf" => {
|
||||||
|
let id = v["widget_id"].as_u64().ok_or_else(|| err("missing widget_id"))? as u16;
|
||||||
|
Ok(LayoutNode::Leaf(id))
|
||||||
|
}
|
||||||
|
"container" => {
|
||||||
|
let direction = match v["direction"].as_str().ok_or_else(|| err("missing direction"))? {
|
||||||
|
"row" => Direction::Row,
|
||||||
|
"column" => Direction::Column,
|
||||||
|
d => return Err(err(&format!("unknown direction: {d}"))),
|
||||||
|
};
|
||||||
|
let gap = v["gap"].as_u64().unwrap_or(0) as u8;
|
||||||
|
let padding = v["padding"].as_u64().unwrap_or(0) as u8;
|
||||||
|
let children = v["children"].as_array()
|
||||||
|
.ok_or_else(|| err("missing children"))?
|
||||||
|
.iter()
|
||||||
|
.map(|ch| {
|
||||||
|
let sizing_v = &ch["sizing"];
|
||||||
|
let sizing = match sizing_v["type"].as_str().ok_or_else(|| err("missing sizing type"))? {
|
||||||
|
"fixed" => Sizing::Fixed(sizing_v["value"].as_u64().ok_or_else(|| err("missing fixed value"))? as u16),
|
||||||
|
"flex" => Sizing::Flex(sizing_v["value"].as_u64().ok_or_else(|| err("missing flex value"))? as u8),
|
||||||
|
s => return Err(err(&format!("unknown sizing: {s}"))),
|
||||||
|
};
|
||||||
|
let node = node_from_json(&ch["node"])?;
|
||||||
|
Ok(LayoutChild { sizing, node })
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
|
Ok(LayoutNode::Container(ContainerNode { direction, gap, padding, children }))
|
||||||
|
}
|
||||||
|
t => Err(err(&format!("unknown node type: {t}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn widget_from_row(row: &SqliteRow) -> Result<WidgetConfig, SqliteConfigError> {
|
||||||
|
let id: i64 = row.get("id");
|
||||||
|
let name: String = row.get("name");
|
||||||
|
let hint_str: String = row.get("display_hint");
|
||||||
|
let ds_id: i64 = row.get("data_source_id");
|
||||||
|
let mappings_json: String = row.get("mappings");
|
||||||
|
let max_size: i64 = row.get("max_data_size");
|
||||||
|
|
||||||
|
Ok(WidgetConfig {
|
||||||
|
id: id as u16,
|
||||||
|
name,
|
||||||
|
display_hint: display_hint_from_str(&hint_str)?,
|
||||||
|
data_source_id: ds_id as u16,
|
||||||
|
mappings: mappings_from_json(&mappings_json)?,
|
||||||
|
max_data_size: max_size as u16,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn data_source_from_row(row: &SqliteRow) -> Result<DataSource, SqliteConfigError> {
|
||||||
|
let id: i64 = row.get("id");
|
||||||
|
let name: String = row.get("name");
|
||||||
|
let type_str: String = row.get("source_type");
|
||||||
|
let interval_secs: i64 = row.get("poll_interval_secs");
|
||||||
|
let config_json: String = row.get("config");
|
||||||
|
|
||||||
|
Ok(DataSource {
|
||||||
|
id: id as u16,
|
||||||
|
name,
|
||||||
|
source_type: data_source_type_from_str(&type_str)?,
|
||||||
|
poll_interval: Duration::from_secs(interval_secs as u64),
|
||||||
|
config: data_source_config_from_json(&config_json)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn preset_from_row(row: &SqliteRow) -> Result<LayoutPreset, SqliteConfigError> {
|
||||||
|
let id: i64 = row.get("id");
|
||||||
|
let name: String = row.get("name");
|
||||||
|
let layout_json: String = row.get("layout_data");
|
||||||
|
|
||||||
|
Ok(LayoutPreset {
|
||||||
|
id: id as u16,
|
||||||
|
name,
|
||||||
|
layout: layout_from_json(&layout_json)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
203
crates/adapters/config-sqlite/tests/config_store_tests.rs
Normal file
203
crates/adapters/config-sqlite/tests/config_store_tests.rs
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
use domain::{
|
||||||
|
ConfigRepository, DisplayHint, KeyMapping, WidgetConfig,
|
||||||
|
DataSource, DataSourceConfig, DataSourceType,
|
||||||
|
Layout, LayoutNode, ContainerNode, LayoutChild, Direction, Sizing,
|
||||||
|
LayoutPreset,
|
||||||
|
};
|
||||||
|
use config_sqlite::SqliteConfigStore;
|
||||||
|
|
||||||
|
async fn test_store() -> SqliteConfigStore {
|
||||||
|
SqliteConfigStore::new("sqlite::memory:").await.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn weather_widget() -> WidgetConfig {
|
||||||
|
WidgetConfig {
|
||||||
|
id: 1,
|
||||||
|
name: "weather".into(),
|
||||||
|
display_hint: DisplayHint::IconValue,
|
||||||
|
data_source_id: 10,
|
||||||
|
mappings: vec![
|
||||||
|
KeyMapping { source_path: "$.temp".into(), target_key: "temperature".into() },
|
||||||
|
KeyMapping { source_path: "$.icon".into(), target_key: "icon".into() },
|
||||||
|
],
|
||||||
|
max_data_size: 2048,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn weather_source() -> DataSource {
|
||||||
|
DataSource {
|
||||||
|
id: 10,
|
||||||
|
name: "openweather".into(),
|
||||||
|
source_type: DataSourceType::Weather,
|
||||||
|
poll_interval: Duration::from_secs(300),
|
||||||
|
config: DataSourceConfig {
|
||||||
|
url: Some("https://api.openweather.org".into()),
|
||||||
|
headers: vec![],
|
||||||
|
api_key: Some("test-key".into()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_layout() -> Layout {
|
||||||
|
Layout {
|
||||||
|
root: LayoutNode::Container(ContainerNode {
|
||||||
|
direction: Direction::Row,
|
||||||
|
gap: 4,
|
||||||
|
padding: 2,
|
||||||
|
children: vec![
|
||||||
|
LayoutChild { sizing: Sizing::Flex(1), node: LayoutNode::Leaf(1) },
|
||||||
|
LayoutChild { sizing: Sizing::Fixed(80), node: LayoutNode::Leaf(2) },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn save_and_retrieve_widget() {
|
||||||
|
let store = test_store().await;
|
||||||
|
store.save_widget(&weather_widget()).await.unwrap();
|
||||||
|
|
||||||
|
let w = store.get_widget(1).await.unwrap().unwrap();
|
||||||
|
assert_eq!(w.id, 1);
|
||||||
|
assert_eq!(w.name, "weather");
|
||||||
|
assert_eq!(w.display_hint, DisplayHint::IconValue);
|
||||||
|
assert_eq!(w.data_source_id, 10);
|
||||||
|
assert_eq!(w.mappings.len(), 2);
|
||||||
|
assert_eq!(w.mappings[0].source_path, "$.temp");
|
||||||
|
assert_eq!(w.max_data_size, 2048);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_nonexistent_widget_returns_none() {
|
||||||
|
let store = test_store().await;
|
||||||
|
assert!(store.get_widget(99).await.unwrap().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_widgets_returns_all() {
|
||||||
|
let store = test_store().await;
|
||||||
|
store.save_widget(&weather_widget()).await.unwrap();
|
||||||
|
store.save_widget(&WidgetConfig {
|
||||||
|
id: 2,
|
||||||
|
name: "portfolio".into(),
|
||||||
|
display_hint: DisplayHint::KeyValue,
|
||||||
|
data_source_id: 20,
|
||||||
|
mappings: vec![],
|
||||||
|
max_data_size: 1024,
|
||||||
|
}).await.unwrap();
|
||||||
|
|
||||||
|
let widgets = store.list_widgets().await.unwrap();
|
||||||
|
assert_eq!(widgets.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn delete_widget_removes_it() {
|
||||||
|
let store = test_store().await;
|
||||||
|
store.save_widget(&weather_widget()).await.unwrap();
|
||||||
|
store.delete_widget(1).await.unwrap();
|
||||||
|
assert!(store.get_widget(1).await.unwrap().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn save_and_retrieve_data_source() {
|
||||||
|
let store = test_store().await;
|
||||||
|
store.save_data_source(&weather_source()).await.unwrap();
|
||||||
|
|
||||||
|
let ds = store.get_data_source(10).await.unwrap().unwrap();
|
||||||
|
assert_eq!(ds.id, 10);
|
||||||
|
assert_eq!(ds.name, "openweather");
|
||||||
|
assert_eq!(ds.source_type, DataSourceType::Weather);
|
||||||
|
assert_eq!(ds.poll_interval, Duration::from_secs(300));
|
||||||
|
assert_eq!(ds.config.url, Some("https://api.openweather.org".into()));
|
||||||
|
assert_eq!(ds.config.api_key, Some("test-key".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_and_delete_data_sources() {
|
||||||
|
let store = test_store().await;
|
||||||
|
store.save_data_source(&weather_source()).await.unwrap();
|
||||||
|
assert_eq!(store.list_data_sources().await.unwrap().len(), 1);
|
||||||
|
|
||||||
|
store.delete_data_source(10).await.unwrap();
|
||||||
|
assert!(store.list_data_sources().await.unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn save_and_retrieve_layout() {
|
||||||
|
let store = test_store().await;
|
||||||
|
let layout = test_layout();
|
||||||
|
store.save_layout(&layout).await.unwrap();
|
||||||
|
|
||||||
|
let loaded = store.get_layout().await.unwrap().unwrap();
|
||||||
|
assert_eq!(loaded, layout);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn layout_starts_as_none() {
|
||||||
|
let store = test_store().await;
|
||||||
|
assert!(store.get_layout().await.unwrap().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn save_layout_replaces_previous() {
|
||||||
|
let store = test_store().await;
|
||||||
|
store.save_layout(&test_layout()).await.unwrap();
|
||||||
|
|
||||||
|
let new_layout = Layout {
|
||||||
|
root: LayoutNode::Leaf(42),
|
||||||
|
};
|
||||||
|
store.save_layout(&new_layout).await.unwrap();
|
||||||
|
|
||||||
|
let loaded = store.get_layout().await.unwrap().unwrap();
|
||||||
|
assert_eq!(loaded, new_layout);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn save_and_retrieve_preset() {
|
||||||
|
let store = test_store().await;
|
||||||
|
let preset = LayoutPreset {
|
||||||
|
id: 1,
|
||||||
|
name: "dashboard".into(),
|
||||||
|
layout: test_layout(),
|
||||||
|
};
|
||||||
|
store.save_preset(&preset).await.unwrap();
|
||||||
|
|
||||||
|
let loaded = store.get_preset(1).await.unwrap().unwrap();
|
||||||
|
assert_eq!(loaded.id, 1);
|
||||||
|
assert_eq!(loaded.name, "dashboard");
|
||||||
|
assert_eq!(loaded.layout, test_layout());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_and_delete_presets() {
|
||||||
|
let store = test_store().await;
|
||||||
|
store.save_preset(&LayoutPreset {
|
||||||
|
id: 1, name: "a".into(), layout: test_layout(),
|
||||||
|
}).await.unwrap();
|
||||||
|
store.save_preset(&LayoutPreset {
|
||||||
|
id: 2, name: "b".into(), layout: test_layout(),
|
||||||
|
}).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(store.list_presets().await.unwrap().len(), 2);
|
||||||
|
|
||||||
|
store.delete_preset(1).await.unwrap();
|
||||||
|
assert_eq!(store.list_presets().await.unwrap().len(), 1);
|
||||||
|
assert!(store.get_preset(1).await.unwrap().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn save_widget_updates_existing() {
|
||||||
|
let store = test_store().await;
|
||||||
|
store.save_widget(&weather_widget()).await.unwrap();
|
||||||
|
|
||||||
|
let mut updated = weather_widget();
|
||||||
|
updated.name = "updated_weather".into();
|
||||||
|
updated.max_data_size = 4096;
|
||||||
|
store.save_widget(&updated).await.unwrap();
|
||||||
|
|
||||||
|
let loaded = store.get_widget(1).await.unwrap().unwrap();
|
||||||
|
assert_eq!(loaded.name, "updated_weather");
|
||||||
|
assert_eq!(loaded.max_data_size, 4096);
|
||||||
|
assert_eq!(store.list_widgets().await.unwrap().len(), 1);
|
||||||
|
}
|
||||||
19
crates/adapters/http-api/Cargo.toml
Normal file
19
crates/adapters/http-api/Cargo.toml
Normal 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
|
||||||
285
crates/adapters/http-api/src/dto.rs
Normal file
285
crates/adapters/http-api/src/dto.rs
Normal 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()?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
34
crates/adapters/http-api/src/lib.rs
Normal file
34
crates/adapters/http-api/src/lib.rs
Normal 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)
|
||||||
|
}
|
||||||
176
crates/adapters/http-api/src/routes.rs
Normal file
176
crates/adapters/http-api/src/routes.rs
Normal 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)
|
||||||
|
}
|
||||||
149
crates/adapters/http-api/tests/api_tests.rs
Normal file
149
crates/adapters/http-api/tests/api_tests.rs
Normal 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);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
use std::cell::RefCell;
|
use std::sync::Mutex;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use domain::{
|
use domain::{
|
||||||
ConfigRepository, EventPublisher,
|
ConfigRepository, EventPublisher,
|
||||||
@@ -7,19 +7,19 @@ use domain::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
pub struct InMemoryConfigRepository {
|
pub struct InMemoryConfigRepository {
|
||||||
pub widgets: RefCell<HashMap<WidgetId, WidgetConfig>>,
|
widgets: Mutex<HashMap<WidgetId, WidgetConfig>>,
|
||||||
pub data_sources: RefCell<HashMap<DataSourceId, DataSource>>,
|
data_sources: Mutex<HashMap<DataSourceId, DataSource>>,
|
||||||
pub layout: RefCell<Option<Layout>>,
|
layout: Mutex<Option<Layout>>,
|
||||||
pub presets: RefCell<HashMap<LayoutPresetId, LayoutPreset>>,
|
presets: Mutex<HashMap<LayoutPresetId, LayoutPreset>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl InMemoryConfigRepository {
|
impl InMemoryConfigRepository {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
widgets: RefCell::new(HashMap::new()),
|
widgets: Mutex::new(HashMap::new()),
|
||||||
data_sources: RefCell::new(HashMap::new()),
|
data_sources: Mutex::new(HashMap::new()),
|
||||||
layout: RefCell::new(None),
|
layout: Mutex::new(None),
|
||||||
presets: RefCell::new(HashMap::new()),
|
presets: Mutex::new(HashMap::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -37,82 +37,82 @@ impl ConfigRepository for InMemoryConfigRepository {
|
|||||||
type Error = Never;
|
type Error = Never;
|
||||||
|
|
||||||
async fn get_widget(&self, id: WidgetId) -> Result<Option<WidgetConfig>, Self::Error> {
|
async fn get_widget(&self, id: WidgetId) -> Result<Option<WidgetConfig>, Self::Error> {
|
||||||
Ok(self.widgets.borrow().get(&id).cloned())
|
Ok(self.widgets.lock().unwrap().get(&id).cloned())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_widgets(&self) -> Result<Vec<WidgetConfig>, Self::Error> {
|
async fn list_widgets(&self) -> Result<Vec<WidgetConfig>, Self::Error> {
|
||||||
Ok(self.widgets.borrow().values().cloned().collect())
|
Ok(self.widgets.lock().unwrap().values().cloned().collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn save_widget(&self, config: &WidgetConfig) -> Result<(), Self::Error> {
|
async fn save_widget(&self, config: &WidgetConfig) -> Result<(), Self::Error> {
|
||||||
self.widgets.borrow_mut().insert(config.id, config.clone());
|
self.widgets.lock().unwrap().insert(config.id, config.clone());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_widget(&self, id: WidgetId) -> Result<(), Self::Error> {
|
async fn delete_widget(&self, id: WidgetId) -> Result<(), Self::Error> {
|
||||||
self.widgets.borrow_mut().remove(&id);
|
self.widgets.lock().unwrap().remove(&id);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_data_source(&self, id: DataSourceId) -> Result<Option<DataSource>, Self::Error> {
|
async fn get_data_source(&self, id: DataSourceId) -> Result<Option<DataSource>, Self::Error> {
|
||||||
Ok(self.data_sources.borrow().get(&id).cloned())
|
Ok(self.data_sources.lock().unwrap().get(&id).cloned())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_data_sources(&self) -> Result<Vec<DataSource>, Self::Error> {
|
async fn list_data_sources(&self) -> Result<Vec<DataSource>, Self::Error> {
|
||||||
Ok(self.data_sources.borrow().values().cloned().collect())
|
Ok(self.data_sources.lock().unwrap().values().cloned().collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn save_data_source(&self, source: &DataSource) -> Result<(), Self::Error> {
|
async fn save_data_source(&self, source: &DataSource) -> Result<(), Self::Error> {
|
||||||
self.data_sources.borrow_mut().insert(source.id, source.clone());
|
self.data_sources.lock().unwrap().insert(source.id, source.clone());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_data_source(&self, id: DataSourceId) -> Result<(), Self::Error> {
|
async fn delete_data_source(&self, id: DataSourceId) -> Result<(), Self::Error> {
|
||||||
self.data_sources.borrow_mut().remove(&id);
|
self.data_sources.lock().unwrap().remove(&id);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_layout(&self) -> Result<Option<Layout>, Self::Error> {
|
async fn get_layout(&self) -> Result<Option<Layout>, Self::Error> {
|
||||||
Ok(self.layout.borrow().clone())
|
Ok(self.layout.lock().unwrap().clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn save_layout(&self, layout: &Layout) -> Result<(), Self::Error> {
|
async fn save_layout(&self, layout: &Layout) -> Result<(), Self::Error> {
|
||||||
*self.layout.borrow_mut() = Some(layout.clone());
|
*self.layout.lock().unwrap() = Some(layout.clone());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_preset(&self, id: LayoutPresetId) -> Result<Option<LayoutPreset>, Self::Error> {
|
async fn get_preset(&self, id: LayoutPresetId) -> Result<Option<LayoutPreset>, Self::Error> {
|
||||||
Ok(self.presets.borrow().get(&id).cloned())
|
Ok(self.presets.lock().unwrap().get(&id).cloned())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_presets(&self) -> Result<Vec<LayoutPreset>, Self::Error> {
|
async fn list_presets(&self) -> Result<Vec<LayoutPreset>, Self::Error> {
|
||||||
Ok(self.presets.borrow().values().cloned().collect())
|
Ok(self.presets.lock().unwrap().values().cloned().collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn save_preset(&self, preset: &LayoutPreset) -> Result<(), Self::Error> {
|
async fn save_preset(&self, preset: &LayoutPreset) -> Result<(), Self::Error> {
|
||||||
self.presets.borrow_mut().insert(preset.id, preset.clone());
|
self.presets.lock().unwrap().insert(preset.id, preset.clone());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_preset(&self, id: LayoutPresetId) -> Result<(), Self::Error> {
|
async fn delete_preset(&self, id: LayoutPresetId) -> Result<(), Self::Error> {
|
||||||
self.presets.borrow_mut().remove(&id);
|
self.presets.lock().unwrap().remove(&id);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct InMemoryEventPublisher {
|
pub struct InMemoryEventPublisher {
|
||||||
pub events: RefCell<Vec<DomainEvent>>,
|
events: Mutex<Vec<DomainEvent>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl InMemoryEventPublisher {
|
impl InMemoryEventPublisher {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
events: RefCell::new(Vec::new()),
|
events: Mutex::new(Vec::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn emitted(&self) -> Vec<DomainEvent> {
|
pub fn emitted(&self) -> Vec<DomainEvent> {
|
||||||
self.events.borrow().clone()
|
self.events.lock().unwrap().clone()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +120,7 @@ impl EventPublisher for InMemoryEventPublisher {
|
|||||||
type Error = Never;
|
type Error = Never;
|
||||||
|
|
||||||
async fn publish(&self, event: DomainEvent) -> Result<(), Self::Error> {
|
async fn publish(&self, event: DomainEvent) -> Result<(), Self::Error> {
|
||||||
self.events.borrow_mut().push(event);
|
self.events.lock().unwrap().push(event);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
|
use std::future::Future;
|
||||||
use crate::entities::WidgetId;
|
use crate::entities::WidgetId;
|
||||||
use crate::value_objects::{Layout, WidgetState};
|
use crate::value_objects::{Layout, WidgetState};
|
||||||
|
|
||||||
pub trait BroadcastPort {
|
pub trait BroadcastPort {
|
||||||
type Error;
|
type Error;
|
||||||
|
|
||||||
async fn push_screen_update(
|
fn push_screen_update(
|
||||||
&self,
|
&self,
|
||||||
layout: &Layout,
|
layout: &Layout,
|
||||||
widgets: &[(WidgetId, WidgetState)],
|
widgets: &[(WidgetId, WidgetState)],
|
||||||
) -> Result<(), Self::Error>;
|
) -> impl Future<Output = Result<(), Self::Error>> + Send;
|
||||||
|
|
||||||
async fn push_data_update(
|
fn push_data_update(
|
||||||
&self,
|
&self,
|
||||||
updates: &[(WidgetId, WidgetState)],
|
updates: &[(WidgetId, WidgetState)],
|
||||||
) -> Result<(), Self::Error>;
|
) -> impl Future<Output = Result<(), Self::Error>> + Send;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use std::future::Future;
|
||||||
use crate::entities::{
|
use crate::entities::{
|
||||||
DataSource, DataSourceId, LayoutPreset, LayoutPresetId, WidgetConfig, WidgetId,
|
DataSource, DataSourceId, LayoutPreset, LayoutPresetId, WidgetConfig, WidgetId,
|
||||||
};
|
};
|
||||||
@@ -6,21 +7,21 @@ use crate::value_objects::Layout;
|
|||||||
pub trait ConfigRepository {
|
pub trait ConfigRepository {
|
||||||
type Error;
|
type Error;
|
||||||
|
|
||||||
async fn get_widget(&self, id: WidgetId) -> Result<Option<WidgetConfig>, Self::Error>;
|
fn get_widget(&self, id: WidgetId) -> impl Future<Output = Result<Option<WidgetConfig>, Self::Error>> + Send;
|
||||||
async fn list_widgets(&self) -> Result<Vec<WidgetConfig>, Self::Error>;
|
fn list_widgets(&self) -> impl Future<Output = Result<Vec<WidgetConfig>, Self::Error>> + Send;
|
||||||
async fn save_widget(&self, config: &WidgetConfig) -> Result<(), Self::Error>;
|
fn save_widget(&self, config: &WidgetConfig) -> impl Future<Output = Result<(), Self::Error>> + Send;
|
||||||
async fn delete_widget(&self, id: WidgetId) -> Result<(), Self::Error>;
|
fn delete_widget(&self, id: WidgetId) -> impl Future<Output = Result<(), Self::Error>> + Send;
|
||||||
|
|
||||||
async fn get_data_source(&self, id: DataSourceId) -> Result<Option<DataSource>, Self::Error>;
|
fn get_data_source(&self, id: DataSourceId) -> impl Future<Output = Result<Option<DataSource>, Self::Error>> + Send;
|
||||||
async fn list_data_sources(&self) -> Result<Vec<DataSource>, Self::Error>;
|
fn list_data_sources(&self) -> impl Future<Output = Result<Vec<DataSource>, Self::Error>> + Send;
|
||||||
async fn save_data_source(&self, source: &DataSource) -> Result<(), Self::Error>;
|
fn save_data_source(&self, source: &DataSource) -> impl Future<Output = Result<(), Self::Error>> + Send;
|
||||||
async fn delete_data_source(&self, id: DataSourceId) -> Result<(), Self::Error>;
|
fn delete_data_source(&self, id: DataSourceId) -> impl Future<Output = Result<(), Self::Error>> + Send;
|
||||||
|
|
||||||
async fn get_layout(&self) -> Result<Option<Layout>, Self::Error>;
|
fn get_layout(&self) -> impl Future<Output = Result<Option<Layout>, Self::Error>> + Send;
|
||||||
async fn save_layout(&self, layout: &Layout) -> Result<(), Self::Error>;
|
fn save_layout(&self, layout: &Layout) -> impl Future<Output = Result<(), Self::Error>> + Send;
|
||||||
|
|
||||||
async fn get_preset(&self, id: LayoutPresetId) -> Result<Option<LayoutPreset>, Self::Error>;
|
fn get_preset(&self, id: LayoutPresetId) -> impl Future<Output = Result<Option<LayoutPreset>, Self::Error>> + Send;
|
||||||
async fn list_presets(&self) -> Result<Vec<LayoutPreset>, Self::Error>;
|
fn list_presets(&self) -> impl Future<Output = Result<Vec<LayoutPreset>, Self::Error>> + Send;
|
||||||
async fn save_preset(&self, preset: &LayoutPreset) -> Result<(), Self::Error>;
|
fn save_preset(&self, preset: &LayoutPreset) -> impl Future<Output = Result<(), Self::Error>> + Send;
|
||||||
async fn delete_preset(&self, id: LayoutPresetId) -> Result<(), Self::Error>;
|
fn delete_preset(&self, id: LayoutPresetId) -> impl Future<Output = Result<(), Self::Error>> + Send;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
use std::future::Future;
|
||||||
use crate::entities::DataSource;
|
use crate::entities::DataSource;
|
||||||
use crate::value_objects::Value;
|
use crate::value_objects::Value;
|
||||||
|
|
||||||
pub trait DataSourcePort {
|
pub trait DataSourcePort {
|
||||||
type Error;
|
type Error;
|
||||||
|
|
||||||
async fn poll(&self, source: &DataSource) -> Result<Value, Self::Error>;
|
fn poll(&self, source: &DataSource) -> impl Future<Output = Result<Value, Self::Error>> + Send;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
use std::future::Future;
|
||||||
use crate::events::DomainEvent;
|
use crate::events::DomainEvent;
|
||||||
|
|
||||||
pub trait EventPublisher {
|
pub trait EventPublisher {
|
||||||
type Error;
|
type Error;
|
||||||
|
|
||||||
async fn publish(&self, event: DomainEvent) -> Result<(), Self::Error>;
|
fn publish(&self, event: DomainEvent) -> impl Future<Output = Result<(), Self::Error>> + Send;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user