diff --git a/crates/adapters/config-sqlite/src/error.rs b/crates/adapters/config-sqlite/src/error.rs new file mode 100644 index 0000000..7e607d6 --- /dev/null +++ b/crates/adapters/config-sqlite/src/error.rs @@ -0,0 +1,14 @@ +#[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}"), + } + } +} diff --git a/crates/adapters/config-sqlite/src/lib.rs b/crates/adapters/config-sqlite/src/lib.rs index 571f617..693f588 100644 --- a/crates/adapters/config-sqlite/src/lib.rs +++ b/crates/adapters/config-sqlite/src/lib.rs @@ -1,29 +1,10 @@ +pub mod error; mod serialization; +mod repository; -use std::time::Duration; -use sqlx::{SqlitePool, Row}; -use domain::{ - ConfigRepository, - DataSource, DataSourceId, DataSourceConfig, DataSourceType, - Layout, LayoutPreset, LayoutPresetId, - WidgetConfig, WidgetId, -}; -use serialization as ser; +use sqlx::SqlitePool; -#[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 use error::SqliteConfigError; pub struct SqliteConfigStore { pool: SqlitePool, @@ -77,186 +58,3 @@ impl SqliteConfigStore { Ok(()) } } - -impl ConfigRepository for SqliteConfigStore { - type Error = SqliteConfigError; - - async fn get_widget(&self, id: WidgetId) -> Result, 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, 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, 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, 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, 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, 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, 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(()) - } -} diff --git a/crates/adapters/config-sqlite/src/repository/data_sources.rs b/crates/adapters/config-sqlite/src/repository/data_sources.rs new file mode 100644 index 0000000..78a1fc9 --- /dev/null +++ b/crates/adapters/config-sqlite/src/repository/data_sources.rs @@ -0,0 +1,57 @@ +use domain::{DataSource, DataSourceId}; +use crate::SqliteConfigStore; +use crate::error::SqliteConfigError; +use crate::serialization::data_source as ser; + +impl SqliteConfigStore { + pub(crate) async fn get_data_source_impl(&self, id: DataSourceId) -> Result, SqliteConfigError> { + 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)?)), + } + } + + pub(crate) async fn list_data_sources_impl(&self) -> Result, SqliteConfigError> { + 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() + } + + pub(crate) async fn save_data_source_impl(&self, source: &DataSource) -> Result<(), SqliteConfigError> { + 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(()) + } + + pub(crate) async fn delete_data_source_impl(&self, id: DataSourceId) -> Result<(), SqliteConfigError> { + sqlx::query("DELETE FROM data_sources WHERE id = ?") + .bind(id as i64) + .execute(&self.pool) + .await + .map_err(SqliteConfigError::Sql)?; + Ok(()) + } +} diff --git a/crates/adapters/config-sqlite/src/repository/layout.rs b/crates/adapters/config-sqlite/src/repository/layout.rs new file mode 100644 index 0000000..a4149a3 --- /dev/null +++ b/crates/adapters/config-sqlite/src/repository/layout.rs @@ -0,0 +1,36 @@ +use sqlx::Row; +use domain::Layout; +use crate::SqliteConfigStore; +use crate::error::SqliteConfigError; +use crate::serialization::layout as ser; + +impl SqliteConfigStore { + pub(crate) async fn get_layout_impl(&self) -> Result, SqliteConfigError> { + 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)?)) + } + } + } + + pub(crate) async fn save_layout_impl(&self, layout: &Layout) -> Result<(), SqliteConfigError> { + 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(()) + } +} diff --git a/crates/adapters/config-sqlite/src/repository/mod.rs b/crates/adapters/config-sqlite/src/repository/mod.rs new file mode 100644 index 0000000..6753196 --- /dev/null +++ b/crates/adapters/config-sqlite/src/repository/mod.rs @@ -0,0 +1,73 @@ +mod widgets; +mod data_sources; +mod layout; +mod presets; + +use domain::{ + ConfigRepository, + DataSource, DataSourceId, + Layout, LayoutPreset, LayoutPresetId, + WidgetConfig, WidgetId, +}; +use crate::SqliteConfigStore; +use crate::error::SqliteConfigError; + +impl ConfigRepository for SqliteConfigStore { + type Error = SqliteConfigError; + + async fn get_widget(&self, id: WidgetId) -> Result, Self::Error> { + self.get_widget_impl(id).await + } + + async fn list_widgets(&self) -> Result, Self::Error> { + self.list_widgets_impl().await + } + + async fn save_widget(&self, config: &WidgetConfig) -> Result<(), Self::Error> { + self.save_widget_impl(config).await + } + + async fn delete_widget(&self, id: WidgetId) -> Result<(), Self::Error> { + self.delete_widget_impl(id).await + } + + async fn get_data_source(&self, id: DataSourceId) -> Result, Self::Error> { + self.get_data_source_impl(id).await + } + + async fn list_data_sources(&self) -> Result, Self::Error> { + self.list_data_sources_impl().await + } + + async fn save_data_source(&self, source: &DataSource) -> Result<(), Self::Error> { + self.save_data_source_impl(source).await + } + + async fn delete_data_source(&self, id: DataSourceId) -> Result<(), Self::Error> { + self.delete_data_source_impl(id).await + } + + async fn get_layout(&self) -> Result, Self::Error> { + self.get_layout_impl().await + } + + async fn save_layout(&self, layout: &Layout) -> Result<(), Self::Error> { + self.save_layout_impl(layout).await + } + + async fn get_preset(&self, id: LayoutPresetId) -> Result, Self::Error> { + self.get_preset_impl(id).await + } + + async fn list_presets(&self) -> Result, Self::Error> { + self.list_presets_impl().await + } + + async fn save_preset(&self, preset: &LayoutPreset) -> Result<(), Self::Error> { + self.save_preset_impl(preset).await + } + + async fn delete_preset(&self, id: LayoutPresetId) -> Result<(), Self::Error> { + self.delete_preset_impl(id).await + } +} diff --git a/crates/adapters/config-sqlite/src/repository/presets.rs b/crates/adapters/config-sqlite/src/repository/presets.rs new file mode 100644 index 0000000..44e7e5a --- /dev/null +++ b/crates/adapters/config-sqlite/src/repository/presets.rs @@ -0,0 +1,53 @@ +use domain::{LayoutPreset, LayoutPresetId}; +use crate::SqliteConfigStore; +use crate::error::SqliteConfigError; +use crate::serialization::{layout as layout_ser, preset as ser}; + +impl SqliteConfigStore { + pub(crate) async fn get_preset_impl(&self, id: LayoutPresetId) -> Result, SqliteConfigError> { + 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)?)), + } + } + + pub(crate) async fn list_presets_impl(&self) -> Result, SqliteConfigError> { + 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() + } + + pub(crate) async fn save_preset_impl(&self, preset: &LayoutPreset) -> Result<(), SqliteConfigError> { + let layout_json = layout_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(()) + } + + pub(crate) async fn delete_preset_impl(&self, id: LayoutPresetId) -> Result<(), SqliteConfigError> { + sqlx::query("DELETE FROM presets WHERE id = ?") + .bind(id as i64) + .execute(&self.pool) + .await + .map_err(SqliteConfigError::Sql)?; + Ok(()) + } +} diff --git a/crates/adapters/config-sqlite/src/repository/widgets.rs b/crates/adapters/config-sqlite/src/repository/widgets.rs new file mode 100644 index 0000000..fdd3ca7 --- /dev/null +++ b/crates/adapters/config-sqlite/src/repository/widgets.rs @@ -0,0 +1,58 @@ +use domain::{WidgetConfig, WidgetId}; +use crate::SqliteConfigStore; +use crate::error::SqliteConfigError; +use crate::serialization::widget as ser; + +impl SqliteConfigStore { + pub(crate) async fn get_widget_impl(&self, id: WidgetId) -> Result, SqliteConfigError> { + 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)?)), + } + } + + pub(crate) async fn list_widgets_impl(&self) -> Result, SqliteConfigError> { + 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() + } + + pub(crate) async fn save_widget_impl(&self, config: &WidgetConfig) -> Result<(), SqliteConfigError> { + 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(()) + } + + pub(crate) async fn delete_widget_impl(&self, id: WidgetId) -> Result<(), SqliteConfigError> { + sqlx::query("DELETE FROM widgets WHERE id = ?") + .bind(id as i64) + .execute(&self.pool) + .await + .map_err(SqliteConfigError::Sql)?; + Ok(()) + } +} diff --git a/crates/adapters/config-sqlite/src/serialization.rs b/crates/adapters/config-sqlite/src/serialization.rs deleted file mode 100644 index a2d4e1d..0000000 --- a/crates/adapters/config-sqlite/src/serialization.rs +++ /dev/null @@ -1,220 +0,0 @@ -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 { - 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 { - 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 { - let entries: Vec = 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, SqliteConfigError> { - let entries: Vec = 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 { - 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 { - 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 { - 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 { - 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 = 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 { - 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::, _>>()?; - - Ok(LayoutNode::Container(ContainerNode { direction, gap, padding, children })) - } - t => Err(err(&format!("unknown node type: {t}"))), - } -} - -pub fn widget_from_row(row: &SqliteRow) -> Result { - 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 { - 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 { - 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)?, - }) -} diff --git a/crates/adapters/config-sqlite/src/serialization/data_source.rs b/crates/adapters/config-sqlite/src/serialization/data_source.rs new file mode 100644 index 0000000..3fb2587 --- /dev/null +++ b/crates/adapters/config-sqlite/src/serialization/data_source.rs @@ -0,0 +1,70 @@ +use std::time::Duration; +use sqlx::Row; +use sqlx::sqlite::SqliteRow; +use domain::{DataSource, DataSourceConfig, DataSourceType}; +use crate::error::SqliteConfigError; + +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 { + 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 data_source_config_to_json(config: &DataSourceConfig) -> Result { + 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 { + 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 data_source_from_row(row: &SqliteRow) -> Result { + 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)?, + }) +} diff --git a/crates/adapters/config-sqlite/src/serialization/layout.rs b/crates/adapters/config-sqlite/src/serialization/layout.rs new file mode 100644 index 0000000..47eba61 --- /dev/null +++ b/crates/adapters/config-sqlite/src/serialization/layout.rs @@ -0,0 +1,77 @@ +use domain::{ContainerNode, Direction, Layout, LayoutChild, LayoutNode, Sizing}; +use crate::error::SqliteConfigError; + +pub fn layout_to_json(layout: &Layout) -> Result { + 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 { + 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 = 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 { + 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::, _>>()?; + + Ok(LayoutNode::Container(ContainerNode { direction, gap, padding, children })) + } + t => Err(err(&format!("unknown node type: {t}"))), + } +} diff --git a/crates/adapters/config-sqlite/src/serialization/mod.rs b/crates/adapters/config-sqlite/src/serialization/mod.rs new file mode 100644 index 0000000..37f98f1 --- /dev/null +++ b/crates/adapters/config-sqlite/src/serialization/mod.rs @@ -0,0 +1,4 @@ +pub mod widget; +pub mod data_source; +pub mod layout; +pub mod preset; diff --git a/crates/adapters/config-sqlite/src/serialization/preset.rs b/crates/adapters/config-sqlite/src/serialization/preset.rs new file mode 100644 index 0000000..f6e2848 --- /dev/null +++ b/crates/adapters/config-sqlite/src/serialization/preset.rs @@ -0,0 +1,17 @@ +use sqlx::Row; +use sqlx::sqlite::SqliteRow; +use domain::LayoutPreset; +use crate::error::SqliteConfigError; +use super::layout::layout_from_json; + +pub fn preset_from_row(row: &SqliteRow) -> Result { + 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)?, + }) +} diff --git a/crates/adapters/config-sqlite/src/serialization/widget.rs b/crates/adapters/config-sqlite/src/serialization/widget.rs new file mode 100644 index 0000000..ed1be70 --- /dev/null +++ b/crates/adapters/config-sqlite/src/serialization/widget.rs @@ -0,0 +1,63 @@ +use sqlx::Row; +use sqlx::sqlite::SqliteRow; +use domain::{DisplayHint, KeyMapping, WidgetConfig}; +use crate::error::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 { + 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 mappings_to_json(mappings: &[KeyMapping]) -> Result { + let entries: Vec = 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, SqliteConfigError> { + let entries: Vec = 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 widget_from_row(row: &SqliteRow) -> Result { + 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, + }) +} diff --git a/crates/adapters/http-api/src/dto.rs b/crates/adapters/http-api/src/dto.rs deleted file mode 100644 index af576b6..0000000 --- a/crates/adapters/http-api/src/dto.rs +++ /dev/null @@ -1,285 +0,0 @@ -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, - 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, - #[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, - pub api_key: Option, - 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, - #[serde(skip_serializing_if = "Option::is_none")] - pub direction: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub gap: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub padding: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub children: Option>, -} - -#[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 { - 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 { - 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 { - 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::, _>>()?; - - 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 { - 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 { - Ok(LayoutPreset { - id: self.id, - name: self.name, - layout: self.layout.into_domain()?, - }) - } -} diff --git a/crates/adapters/http-api/src/dto/data_source.rs b/crates/adapters/http-api/src/dto/data_source.rs new file mode 100644 index 0000000..b4c67da --- /dev/null +++ b/crates/adapters/http-api/src/dto/data_source.rs @@ -0,0 +1,60 @@ +use serde::{Serialize, Deserialize}; +use std::time::Duration; +use domain::*; + +#[derive(Serialize, Deserialize)] +pub struct DataSourceDto { + pub id: u16, + pub name: String, + pub source_type: String, + pub poll_interval_secs: u64, + pub url: Option, + pub api_key: Option, + pub headers: Vec<(String, String)>, +} + +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 { + 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, + }, + }) + } +} diff --git a/crates/adapters/http-api/src/dto/layout.rs b/crates/adapters/http-api/src/dto/layout.rs new file mode 100644 index 0000000..fad084c --- /dev/null +++ b/crates/adapters/http-api/src/dto/layout.rs @@ -0,0 +1,121 @@ +use serde::{Serialize, Deserialize}; +use domain::*; + +#[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, + #[serde(skip_serializing_if = "Option::is_none")] + pub direction: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub gap: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub padding: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub children: Option>, +} + +#[derive(Serialize, Deserialize)] +pub struct LayoutChildDto { + pub sizing: SizingDto, + pub node: LayoutNodeDto, +} + +#[derive(Serialize, Deserialize)] +pub struct LayoutDto { + pub root: LayoutNodeDto, +} + +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 { + 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::, _>>()?; + + 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 { + Ok(Layout { root: self.root.into_domain()? }) + } +} diff --git a/crates/adapters/http-api/src/dto/mod.rs b/crates/adapters/http-api/src/dto/mod.rs new file mode 100644 index 0000000..3d945ed --- /dev/null +++ b/crates/adapters/http-api/src/dto/mod.rs @@ -0,0 +1,9 @@ +pub mod widget; +pub mod data_source; +pub mod layout; +pub mod preset; + +pub use widget::{KeyMappingDto, WidgetDto, CreateWidgetDto}; +pub use data_source::DataSourceDto; +pub use layout::{LayoutDto, LayoutNodeDto, LayoutChildDto, SizingDto}; +pub use preset::{PresetDto, CreatePresetDto}; diff --git a/crates/adapters/http-api/src/dto/preset.rs b/crates/adapters/http-api/src/dto/preset.rs new file mode 100644 index 0000000..842b563 --- /dev/null +++ b/crates/adapters/http-api/src/dto/preset.rs @@ -0,0 +1,37 @@ +use serde::{Serialize, Deserialize}; +use domain::*; +use super::layout::LayoutDto; + +#[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, +} + +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 { + Ok(LayoutPreset { + id: self.id, + name: self.name, + layout: self.layout.into_domain()?, + }) + } +} diff --git a/crates/adapters/http-api/src/dto/widget.rs b/crates/adapters/http-api/src/dto/widget.rs new file mode 100644 index 0000000..cd9bbc4 --- /dev/null +++ b/crates/adapters/http-api/src/dto/widget.rs @@ -0,0 +1,73 @@ +use serde::{Serialize, Deserialize}; +use domain::*; + +#[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, + 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, + #[serde(default = "default_max_data_size")] + pub max_data_size: u16, +} + +fn default_max_data_size() -> u16 { 2048 } + +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 { + 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, + }) + } +} diff --git a/crates/adapters/http-api/src/routes.rs b/crates/adapters/http-api/src/routes.rs deleted file mode 100644 index a589038..0000000 --- a/crates/adapters/http-api/src/routes.rs +++ /dev/null @@ -1,176 +0,0 @@ -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 = State>; - -pub fn api_routes() -> 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() - .route("/widgets", get(list_widgets::).post(create_widget::)) - .route("/widgets/{id}", get(get_widget::).put(update_widget::).delete(delete_widget::)) - .route("/data-sources", get(list_data_sources::).post(create_data_source::)) - .route("/data-sources/{id}", get(get_data_source::).put(update_data_source::).delete(delete_data_source::)) - .route("/layout", get(get_layout::).put(update_layout::)) - .route("/presets", get(list_presets::).post(create_preset::)) - .route("/presets/{id}", get(get_preset::).delete(delete_preset::)) - .route("/presets/{id}/load", post(load_preset::)) -} - -async fn list_widgets(State(state): S) -> Result>, 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(State(state): S, Path(id): Path) -> Result, 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(State(state): S, Json(body): Json) -> Result -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(State(state): S, Path(_id): Path, Json(body): Json) -> Result -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(State(state): S, Path(id): Path) -> Result -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(State(state): S) -> Result>, 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(State(state): S, Path(id): Path) -> Result, 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(State(state): S, Json(body): Json) -> Result -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(State(state): S, Path(_id): Path, Json(body): Json) -> Result -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(State(state): S, Path(id): Path) -> Result -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(State(state): S) -> Result>, 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(State(state): S, Json(body): Json) -> Result -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(State(state): S) -> Result>, 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(State(state): S, Path(id): Path) -> Result, 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(State(state): S, Json(body): Json) -> Result -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(State(state): S, Path(id): Path) -> Result -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(State(state): S, Path(id): Path) -> Result -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) -} diff --git a/crates/adapters/http-api/src/routes/data_sources.rs b/crates/adapters/http-api/src/routes/data_sources.rs new file mode 100644 index 0000000..f9e1cb7 --- /dev/null +++ b/crates/adapters/http-api/src/routes/data_sources.rs @@ -0,0 +1,54 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::Json, +}; +use domain::{ConfigRepository, EventPublisher}; +use application::ConfigService; +use crate::AppState; +use crate::dto::DataSourceDto; + +type S = State>; + +pub async fn list_data_sources(State(state): S) -> Result>, 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())) +} + +pub async fn get_data_source(State(state): S, Path(id): Path) -> Result, 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), + } +} + +pub async fn create_data_source(State(state): S, Json(body): Json) -> Result +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) +} + +pub async fn update_data_source(State(state): S, Path(_id): Path, Json(body): Json) -> Result +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) +} + +pub async fn delete_data_source(State(state): S, Path(id): Path) -> Result +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) +} diff --git a/crates/adapters/http-api/src/routes/layout.rs b/crates/adapters/http-api/src/routes/layout.rs new file mode 100644 index 0000000..301dfba --- /dev/null +++ b/crates/adapters/http-api/src/routes/layout.rs @@ -0,0 +1,27 @@ +use axum::{ + extract::State, + http::StatusCode, + response::Json, +}; +use domain::{ConfigRepository, EventPublisher}; +use application::ConfigService; +use crate::AppState; +use crate::dto::LayoutDto; + +type S = State>; + +pub async fn get_layout(State(state): S) -> Result>, 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))) +} + +pub async fn update_layout(State(state): S, Json(body): Json) -> Result +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) +} diff --git a/crates/adapters/http-api/src/routes/mod.rs b/crates/adapters/http-api/src/routes/mod.rs new file mode 100644 index 0000000..646e662 --- /dev/null +++ b/crates/adapters/http-api/src/routes/mod.rs @@ -0,0 +1,27 @@ +mod widgets; +mod data_sources; +mod layout; +mod presets; + +use axum::Router; +use axum::routing::{get, post, put, delete}; +use domain::{ConfigRepository, EventPublisher}; +use crate::AppState; + +pub fn api_routes() -> 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() + .route("/widgets", get(widgets::list_widgets::).post(widgets::create_widget::)) + .route("/widgets/{id}", get(widgets::get_widget::).put(widgets::update_widget::).delete(widgets::delete_widget::)) + .route("/data-sources", get(data_sources::list_data_sources::).post(data_sources::create_data_source::)) + .route("/data-sources/{id}", get(data_sources::get_data_source::).put(data_sources::update_data_source::).delete(data_sources::delete_data_source::)) + .route("/layout", get(layout::get_layout::).put(layout::update_layout::)) + .route("/presets", get(presets::list_presets::).post(presets::create_preset::)) + .route("/presets/{id}", get(presets::get_preset::).delete(presets::delete_preset::)) + .route("/presets/{id}/load", post(presets::load_preset::)) +} diff --git a/crates/adapters/http-api/src/routes/presets.rs b/crates/adapters/http-api/src/routes/presets.rs new file mode 100644 index 0000000..0adf1d6 --- /dev/null +++ b/crates/adapters/http-api/src/routes/presets.rs @@ -0,0 +1,53 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::Json, +}; +use domain::{ConfigRepository, EventPublisher}; +use application::ConfigService; +use crate::AppState; +use crate::dto::{PresetDto, CreatePresetDto}; + +type S = State>; + +pub async fn list_presets(State(state): S) -> Result>, 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())) +} + +pub async fn get_preset(State(state): S, Path(id): Path) -> Result, 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), + } +} + +pub async fn create_preset(State(state): S, Json(body): Json) -> Result +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) +} + +pub async fn delete_preset(State(state): S, Path(id): Path) -> Result +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) +} + +pub async fn load_preset(State(state): S, Path(id): Path) -> Result +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) +} diff --git a/crates/adapters/http-api/src/routes/widgets.rs b/crates/adapters/http-api/src/routes/widgets.rs new file mode 100644 index 0000000..a888ebd --- /dev/null +++ b/crates/adapters/http-api/src/routes/widgets.rs @@ -0,0 +1,54 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::Json, +}; +use domain::{ConfigRepository, EventPublisher}; +use application::ConfigService; +use crate::AppState; +use crate::dto::{WidgetDto, CreateWidgetDto}; + +type S = State>; + +pub async fn list_widgets(State(state): S) -> Result>, 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())) +} + +pub async fn get_widget(State(state): S, Path(id): Path) -> Result, 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), + } +} + +pub async fn create_widget(State(state): S, Json(body): Json) -> Result +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) +} + +pub async fn update_widget(State(state): S, Path(_id): Path, Json(body): Json) -> Result +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) +} + +pub async fn delete_widget(State(state): S, Path(id): Path) -> Result +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) +} diff --git a/crates/adapters/media/src/error.rs b/crates/adapters/media/src/error.rs new file mode 100644 index 0000000..3a7f491 --- /dev/null +++ b/crates/adapters/media/src/error.rs @@ -0,0 +1,16 @@ +#[derive(Debug)] +pub enum MediaError { + Request(reqwest::Error), + NoUrl, + Parse(String), +} + +impl std::fmt::Display for MediaError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MediaError::Request(e) => write!(f, "request: {e}"), + MediaError::NoUrl => write!(f, "no url configured"), + MediaError::Parse(e) => write!(f, "parse: {e}"), + } + } +} diff --git a/crates/adapters/media/src/lib.rs b/crates/adapters/media/src/lib.rs index cc1e277..c29db1f 100644 --- a/crates/adapters/media/src/lib.rs +++ b/crates/adapters/media/src/lib.rs @@ -1,3 +1,7 @@ +mod error; + +pub use error::MediaError; + use std::collections::BTreeMap; use domain::{DataSource, DataSourcePort, Value}; @@ -5,23 +9,6 @@ pub struct MediaAdapter { client: reqwest::Client, } -#[derive(Debug)] -pub enum MediaError { - Request(reqwest::Error), - NoUrl, - Parse(String), -} - -impl std::fmt::Display for MediaError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - MediaError::Request(e) => write!(f, "request: {e}"), - MediaError::NoUrl => write!(f, "no url configured"), - MediaError::Parse(e) => write!(f, "parse: {e}"), - } - } -} - impl MediaAdapter { pub fn new() -> Self { Self { @@ -75,84 +62,3 @@ impl DataSourcePort for MediaAdapter { Ok(Value::Object(result)) } } - -#[cfg(test)] -mod tests { - use super::*; - use std::time::Duration; - use domain::{DataSourceConfig, DataSourceType}; - - fn subsonic_response(playing: bool) -> serde_json::Value { - if playing { - serde_json::json!({ - "subsonic-response": { - "status": "ok", - "nowPlaying": { - "entry": [{ - "title": "Believer", - "artist": "Imagine Dragons", - "album": "Evolve", - "duration": 204 - }] - } - } - }) - } else { - serde_json::json!({ - "subsonic-response": { - "status": "ok", - "nowPlaying": {} - } - }) - } - } - - async fn start_fake_subsonic(playing: bool) -> String { - let app = axum::Router::new() - .route("/rest/getNowPlaying.view", axum::routing::get(move || async move { - axum::response::Json(subsonic_response(playing)) - })); - - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); - format!("http://{addr}") - } - - fn make_source(url: String) -> DataSource { - DataSource { - id: 1, - name: "navidrome".into(), - source_type: DataSourceType::Media, - poll_interval: Duration::from_secs(5), - config: DataSourceConfig { - url: Some(url), - headers: vec![], - api_key: Some("testtoken".into()), - }, - } - } - - #[tokio::test] - async fn returns_now_playing_info() { - let base = start_fake_subsonic(true).await; - let adapter = MediaAdapter::new(); - let source = make_source(base); - - let result = adapter.poll(&source).await.unwrap(); - - assert_eq!(result.get_path("$.playing"), Some(&Value::Bool(true))); - assert_eq!(result.get_path("$.title"), Some(&Value::String("Believer".into()))); - assert_eq!(result.get_path("$.artist"), Some(&Value::String("Imagine Dragons".into()))); - } - - #[tokio::test] - async fn returns_not_playing_when_empty() { - let base = start_fake_subsonic(false).await; - let adapter = MediaAdapter::new(); - let source = make_source(base); - - let result = adapter.poll(&source).await.unwrap(); - assert_eq!(result.get_path("$.playing"), Some(&Value::Bool(false))); - } -} diff --git a/crates/adapters/media/tests/media_tests.rs b/crates/adapters/media/tests/media_tests.rs new file mode 100644 index 0000000..56bbeee --- /dev/null +++ b/crates/adapters/media/tests/media_tests.rs @@ -0,0 +1,77 @@ +use std::time::Duration; +use domain::{DataSource, DataSourceConfig, DataSourcePort, DataSourceType, Value}; +use media_adapter::MediaAdapter; + +fn subsonic_response(playing: bool) -> serde_json::Value { + if playing { + serde_json::json!({ + "subsonic-response": { + "status": "ok", + "nowPlaying": { + "entry": [{ + "title": "Believer", + "artist": "Imagine Dragons", + "album": "Evolve", + "duration": 204 + }] + } + } + }) + } else { + serde_json::json!({ + "subsonic-response": { + "status": "ok", + "nowPlaying": {} + } + }) + } +} + +async fn start_fake_subsonic(playing: bool) -> String { + let app = axum::Router::new() + .route("/rest/getNowPlaying.view", axum::routing::get(move || async move { + axum::response::Json(subsonic_response(playing)) + })); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + format!("http://{addr}") +} + +fn make_source(url: String) -> DataSource { + DataSource { + id: 1, + name: "navidrome".into(), + source_type: DataSourceType::Media, + poll_interval: Duration::from_secs(5), + config: DataSourceConfig { + url: Some(url), + headers: vec![], + api_key: Some("testtoken".into()), + }, + } +} + +#[tokio::test] +async fn returns_now_playing_info() { + let base = start_fake_subsonic(true).await; + let adapter = MediaAdapter::new(); + let source = make_source(base); + + let result = adapter.poll(&source).await.unwrap(); + + assert_eq!(result.get_path("$.playing"), Some(&Value::Bool(true))); + assert_eq!(result.get_path("$.title"), Some(&Value::String("Believer".into()))); + assert_eq!(result.get_path("$.artist"), Some(&Value::String("Imagine Dragons".into()))); +} + +#[tokio::test] +async fn returns_not_playing_when_empty() { + let base = start_fake_subsonic(false).await; + let adapter = MediaAdapter::new(); + let source = make_source(base); + + let result = adapter.poll(&source).await.unwrap(); + assert_eq!(result.get_path("$.playing"), Some(&Value::Bool(false))); +} diff --git a/crates/adapters/rss/src/error.rs b/crates/adapters/rss/src/error.rs new file mode 100644 index 0000000..c056a2a --- /dev/null +++ b/crates/adapters/rss/src/error.rs @@ -0,0 +1,16 @@ +#[derive(Debug)] +pub enum RssError { + Request(reqwest::Error), + NoUrl, + Parse(String), +} + +impl std::fmt::Display for RssError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + RssError::Request(e) => write!(f, "request: {e}"), + RssError::NoUrl => write!(f, "no url configured"), + RssError::Parse(e) => write!(f, "parse: {e}"), + } + } +} diff --git a/crates/adapters/rss/src/lib.rs b/crates/adapters/rss/src/lib.rs index 785a9de..f27c39f 100644 --- a/crates/adapters/rss/src/lib.rs +++ b/crates/adapters/rss/src/lib.rs @@ -1,29 +1,15 @@ -use std::collections::BTreeMap; +mod error; +mod parser; + +pub use error::RssError; +pub use parser::parse_rss; + use domain::{DataSource, DataSourcePort, Value}; -use quick_xml::events::Event; -use quick_xml::Reader; pub struct RssAdapter { client: reqwest::Client, } -#[derive(Debug)] -pub enum RssError { - Request(reqwest::Error), - NoUrl, - Parse(String), -} - -impl std::fmt::Display for RssError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - RssError::Request(e) => write!(f, "request: {e}"), - RssError::NoUrl => write!(f, "no url configured"), - RssError::Parse(e) => write!(f, "parse: {e}"), - } - } -} - impl RssAdapter { pub fn new() -> Self { Self { @@ -32,71 +18,6 @@ impl RssAdapter { } } -fn parse_rss(xml: &str) -> Result { - let mut reader = Reader::from_str(xml); - let mut items: Vec = Vec::new(); - let mut current_item: Option> = None; - let mut current_tag = String::new(); - let mut in_channel = false; - let mut channel_title = String::new(); - let mut channel_link = String::new(); - - loop { - match reader.read_event() { - Ok(Event::Start(e)) => { - let tag = String::from_utf8_lossy(e.name().as_ref()).to_string(); - match tag.as_str() { - "channel" => in_channel = true, - "item" => { current_item = Some(BTreeMap::new()); } - _ => current_tag = tag, - } - } - Ok(Event::End(e)) => { - let tag = String::from_utf8_lossy(e.name().as_ref()).to_string(); - if tag == "item" { - if let Some(item) = current_item.take() { - items.push(Value::Object(item)); - } - } - current_tag.clear(); - } - Ok(Event::Text(e)) => { - let text = e.unescape().unwrap_or_default().to_string(); - if !current_tag.is_empty() && !text.trim().is_empty() { - if let Some(item) = current_item.as_mut() { - item.insert(current_tag.clone(), Value::String(text)); - } else if in_channel { - match current_tag.as_str() { - "title" => channel_title = text, - "link" => channel_link = text, - _ => {} - } - } - } - } - Ok(Event::CData(e)) => { - let text = String::from_utf8_lossy(&e).to_string(); - if !current_tag.is_empty() { - if let Some(item) = current_item.as_mut() { - item.insert(current_tag.clone(), Value::String(text)); - } - } - } - Ok(Event::Eof) => break, - Err(e) => return Err(RssError::Parse(format!("{e}"))), - _ => {} - } - } - - let mut result = BTreeMap::new(); - result.insert("title".into(), Value::String(channel_title)); - result.insert("link".into(), Value::String(channel_link)); - result.insert("count".into(), Value::Number(items.len() as f64)); - result.insert("items".into(), Value::Array(items)); - - Ok(Value::Object(result)) -} - impl DataSourcePort for RssAdapter { type Error = RssError; @@ -106,39 +27,6 @@ impl DataSourcePort for RssAdapter { let resp = self.client.get(url).send().await.map_err(RssError::Request)?; let xml = resp.text().await.map_err(RssError::Request)?; - parse_rss(&xml) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - const SAMPLE_RSS: &str = r#" - - - Test Feed - https://example.com - - First Article - Description of first article - https://example.com/1 - - - Second Article - Description of second - https://example.com/2 - - - "#; - - #[test] - fn parses_rss_into_value() { - let result = parse_rss(SAMPLE_RSS).unwrap(); - - assert_eq!(result.get_path("$.title"), Some(&Value::String("Test Feed".into()))); - assert_eq!(result.get_path("$.items[0].title"), Some(&Value::String("First Article".into()))); - assert_eq!(result.get_path("$.items[1].title"), Some(&Value::String("Second Article".into()))); - assert_eq!(result.get_path("$.items[0].description"), Some(&Value::String("Description of first article".into()))); + parser::parse_rss(&xml) } } diff --git a/crates/adapters/rss/src/parser.rs b/crates/adapters/rss/src/parser.rs new file mode 100644 index 0000000..5b88a81 --- /dev/null +++ b/crates/adapters/rss/src/parser.rs @@ -0,0 +1,74 @@ +use std::collections::BTreeMap; + +use domain::Value; +use quick_xml::Reader; +use quick_xml::events::Event; + +use crate::error::RssError; + +pub fn parse_rss(xml: &str) -> Result { + let mut reader = Reader::from_str(xml); + let mut items: Vec = Vec::new(); + let mut current_item: Option> = None; + let mut current_tag = String::new(); + let mut in_channel = false; + let mut channel_title = String::new(); + let mut channel_link = String::new(); + + loop { + match reader.read_event() { + Ok(Event::Start(e)) => { + let tag = String::from_utf8_lossy(e.name().as_ref()).to_string(); + match tag.as_str() { + "channel" => in_channel = true, + "item" => { + current_item = Some(BTreeMap::new()); + } + _ => current_tag = tag, + } + } + Ok(Event::End(e)) => { + let tag = String::from_utf8_lossy(e.name().as_ref()).to_string(); + if tag == "item" { + if let Some(item) = current_item.take() { + items.push(Value::Object(item)); + } + } + current_tag.clear(); + } + Ok(Event::Text(e)) => { + let text = e.unescape().unwrap_or_default().to_string(); + if !current_tag.is_empty() && !text.trim().is_empty() { + if let Some(item) = current_item.as_mut() { + item.insert(current_tag.clone(), Value::String(text)); + } else if in_channel { + match current_tag.as_str() { + "title" => channel_title = text, + "link" => channel_link = text, + _ => {} + } + } + } + } + Ok(Event::CData(e)) => { + let text = String::from_utf8_lossy(&e).to_string(); + if !current_tag.is_empty() { + if let Some(item) = current_item.as_mut() { + item.insert(current_tag.clone(), Value::String(text)); + } + } + } + Ok(Event::Eof) => break, + Err(e) => return Err(RssError::Parse(format!("{e}"))), + _ => {} + } + } + + let mut result = BTreeMap::new(); + result.insert("title".into(), Value::String(channel_title)); + result.insert("link".into(), Value::String(channel_link)); + result.insert("count".into(), Value::Number(items.len() as f64)); + result.insert("items".into(), Value::Array(items)); + + Ok(Value::Object(result)) +} diff --git a/crates/adapters/rss/tests/parser_tests.rs b/crates/adapters/rss/tests/parser_tests.rs new file mode 100644 index 0000000..8163a3d --- /dev/null +++ b/crates/adapters/rss/tests/parser_tests.rs @@ -0,0 +1,30 @@ +use domain::Value; +use rss_adapter::{parse_rss, RssError}; + +const SAMPLE_RSS: &str = r#" + + + Test Feed + https://example.com + + First Article + Description of first article + https://example.com/1 + + + Second Article + Description of second + https://example.com/2 + + + "#; + +#[test] +fn parses_rss_into_value() { + let result = parse_rss(SAMPLE_RSS).unwrap(); + + assert_eq!(result.get_path("$.title"), Some(&Value::String("Test Feed".into()))); + assert_eq!(result.get_path("$.items[0].title"), Some(&Value::String("First Article".into()))); + assert_eq!(result.get_path("$.items[1].title"), Some(&Value::String("Second Article".into()))); + assert_eq!(result.get_path("$.items[0].description"), Some(&Value::String("Description of first article".into()))); +} diff --git a/crates/adapters/tcp-server/src/broadcaster.rs b/crates/adapters/tcp-server/src/broadcaster.rs new file mode 100644 index 0000000..d90716e --- /dev/null +++ b/crates/adapters/tcp-server/src/broadcaster.rs @@ -0,0 +1,76 @@ +use tokio::sync::broadcast; +use domain::{ + BroadcastPort, Layout, WidgetId, WidgetState, +}; +use protocol::{ + ServerMessage, WidgetDescriptor, WireDisplayHint, WireLayoutNode, + encode, +}; +use crate::error::TcpServerError; + +pub struct TcpBroadcaster { + tx: broadcast::Sender>, +} + +impl TcpBroadcaster { + pub fn new(capacity: usize) -> Self { + let (tx, _) = broadcast::channel(capacity); + Self { tx } + } + + pub fn subscribe(&self) -> broadcast::Receiver> { + self.tx.subscribe() + } + + fn send_frame(&self, frame: Vec) -> Result<(), TcpServerError> { + let _ = self.tx.send(frame); + Ok(()) + } +} + +impl BroadcastPort for TcpBroadcaster { + type Error = TcpServerError; + + async fn push_screen_update( + &self, + layout: &Layout, + widgets: &[(WidgetId, WidgetState)], + ) -> Result<(), Self::Error> { + let wire_layout: WireLayoutNode = (&layout.root).into(); + let wire_widgets: Vec = widgets.iter().map(|(id, state)| { + WidgetDescriptor { + id: *id, + display_hint: WireDisplayHint::IconValue, + state: state.into(), + } + }).collect(); + + let msg = ServerMessage::ScreenUpdate { + layout: wire_layout, + widgets: wire_widgets, + }; + + let frame = encode(&msg).map_err(TcpServerError::Encode)?; + self.send_frame(frame) + } + + async fn push_data_update( + &self, + updates: &[(WidgetId, WidgetState)], + ) -> Result<(), Self::Error> { + let wire_widgets: Vec = updates.iter().map(|(id, state)| { + WidgetDescriptor { + id: *id, + display_hint: WireDisplayHint::IconValue, + state: state.into(), + } + }).collect(); + + let msg = ServerMessage::DataUpdate { + widgets: wire_widgets, + }; + + let frame = encode(&msg).map_err(TcpServerError::Encode)?; + self.send_frame(frame) + } +} diff --git a/crates/adapters/tcp-server/src/error.rs b/crates/adapters/tcp-server/src/error.rs new file mode 100644 index 0000000..9b47113 --- /dev/null +++ b/crates/adapters/tcp-server/src/error.rs @@ -0,0 +1,14 @@ +#[derive(Debug)] +pub enum TcpServerError { + Io(std::io::Error), + Encode(postcard::Error), +} + +impl std::fmt::Display for TcpServerError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TcpServerError::Io(e) => write!(f, "io: {e}"), + TcpServerError::Encode(e) => write!(f, "encode: {e}"), + } + } +} diff --git a/crates/adapters/tcp-server/src/event_bus.rs b/crates/adapters/tcp-server/src/event_bus.rs new file mode 100644 index 0000000..e88f6d1 --- /dev/null +++ b/crates/adapters/tcp-server/src/event_bus.rs @@ -0,0 +1,27 @@ +use tokio::sync::broadcast; +use domain::{EventPublisher, DomainEvent}; +use crate::error::TcpServerError; + +pub struct TcpEventBus { + tx: broadcast::Sender, +} + +impl TcpEventBus { + pub fn new(capacity: usize) -> Self { + let (tx, _) = broadcast::channel(capacity); + Self { tx } + } + + pub fn subscribe(&self) -> broadcast::Receiver { + self.tx.subscribe() + } +} + +impl EventPublisher for TcpEventBus { + type Error = TcpServerError; + + async fn publish(&self, event: DomainEvent) -> Result<(), Self::Error> { + let _ = self.tx.send(event); + Ok(()) + } +} diff --git a/crates/adapters/tcp-server/src/lib.rs b/crates/adapters/tcp-server/src/lib.rs index 84fbc89..ab1f12d 100644 --- a/crates/adapters/tcp-server/src/lib.rs +++ b/crates/adapters/tcp-server/src/lib.rs @@ -1,150 +1,9 @@ -use std::sync::Arc; -use tokio::net::TcpListener; -use tokio::sync::broadcast; -use tokio::io::AsyncWriteExt; -use domain::{ - BroadcastPort, EventPublisher, DomainEvent, - Layout, WidgetId, WidgetState, -}; -use protocol::{ - ServerMessage, WidgetDescriptor, WireDisplayHint, WireLayoutNode, - encode, -}; +mod error; +mod broadcaster; +mod event_bus; +mod server; -#[derive(Debug)] -pub enum TcpServerError { - Io(std::io::Error), - Encode(postcard::Error), -} - -impl std::fmt::Display for TcpServerError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - TcpServerError::Io(e) => write!(f, "io: {e}"), - TcpServerError::Encode(e) => write!(f, "encode: {e}"), - } - } -} - -pub struct TcpBroadcaster { - tx: broadcast::Sender>, -} - -impl TcpBroadcaster { - pub fn new(capacity: usize) -> Self { - let (tx, _) = broadcast::channel(capacity); - Self { tx } - } - - pub fn subscribe(&self) -> broadcast::Receiver> { - self.tx.subscribe() - } - - fn send_frame(&self, frame: Vec) -> Result<(), TcpServerError> { - let _ = self.tx.send(frame); - Ok(()) - } -} - -impl BroadcastPort for TcpBroadcaster { - type Error = TcpServerError; - - async fn push_screen_update( - &self, - layout: &Layout, - widgets: &[(WidgetId, WidgetState)], - ) -> Result<(), Self::Error> { - let wire_layout: WireLayoutNode = (&layout.root).into(); - let wire_widgets: Vec = widgets.iter().map(|(id, state)| { - WidgetDescriptor { - id: *id, - display_hint: WireDisplayHint::IconValue, - state: state.into(), - } - }).collect(); - - let msg = ServerMessage::ScreenUpdate { - layout: wire_layout, - widgets: wire_widgets, - }; - - let frame = encode(&msg).map_err(TcpServerError::Encode)?; - self.send_frame(frame) - } - - async fn push_data_update( - &self, - updates: &[(WidgetId, WidgetState)], - ) -> Result<(), Self::Error> { - let wire_widgets: Vec = updates.iter().map(|(id, state)| { - WidgetDescriptor { - id: *id, - display_hint: WireDisplayHint::IconValue, - state: state.into(), - } - }).collect(); - - let msg = ServerMessage::DataUpdate { - widgets: wire_widgets, - }; - - let frame = encode(&msg).map_err(TcpServerError::Encode)?; - self.send_frame(frame) - } -} - -pub struct TcpEventBus { - tx: broadcast::Sender, -} - -impl TcpEventBus { - pub fn new(capacity: usize) -> Self { - let (tx, _) = broadcast::channel(capacity); - Self { tx } - } - - pub fn subscribe(&self) -> broadcast::Receiver { - self.tx.subscribe() - } -} - -impl EventPublisher for TcpEventBus { - type Error = TcpServerError; - - async fn publish(&self, event: DomainEvent) -> Result<(), Self::Error> { - let _ = self.tx.send(event); - Ok(()) - } -} - -pub async fn run_tcp_server( - addr: &str, - broadcaster: Arc, -) -> Result<(), TcpServerError> { - let listener = TcpListener::bind(addr).await.map_err(TcpServerError::Io)?; - println!("TCP server listening on {addr}"); - - loop { - let (mut socket, peer) = listener.accept().await.map_err(TcpServerError::Io)?; - println!("Client connected: {peer}"); - - let mut rx = broadcaster.subscribe(); - - tokio::spawn(async move { - loop { - match rx.recv().await { - Ok(frame) => { - if socket.write_all(&frame).await.is_err() { - println!("Client disconnected: {peer}"); - break; - } - } - Err(broadcast::error::RecvError::Closed) => break, - Err(broadcast::error::RecvError::Lagged(n)) => { - println!("Client {peer} lagged by {n} messages"); - } - } - } - }); - } -} +pub use error::TcpServerError; +pub use broadcaster::TcpBroadcaster; +pub use event_bus::TcpEventBus; +pub use server::run_tcp_server; diff --git a/crates/adapters/tcp-server/src/server.rs b/crates/adapters/tcp-server/src/server.rs new file mode 100644 index 0000000..a7c6b66 --- /dev/null +++ b/crates/adapters/tcp-server/src/server.rs @@ -0,0 +1,38 @@ +use std::sync::Arc; +use tokio::net::TcpListener; +use tokio::sync::broadcast; +use tokio::io::AsyncWriteExt; +use crate::broadcaster::TcpBroadcaster; +use crate::error::TcpServerError; + +pub async fn run_tcp_server( + addr: &str, + broadcaster: Arc, +) -> Result<(), TcpServerError> { + let listener = TcpListener::bind(addr).await.map_err(TcpServerError::Io)?; + println!("TCP server listening on {addr}"); + + loop { + let (mut socket, peer) = listener.accept().await.map_err(TcpServerError::Io)?; + println!("Client connected: {peer}"); + + let mut rx = broadcaster.subscribe(); + + tokio::spawn(async move { + loop { + match rx.recv().await { + Ok(frame) => { + if socket.write_all(&frame).await.is_err() { + println!("Client disconnected: {peer}"); + break; + } + } + Err(broadcast::error::RecvError::Closed) => break, + Err(broadcast::error::RecvError::Lagged(n)) => { + println!("Client {peer} lagged by {n} messages"); + } + } + } + }); + } +}