add SPA config UI, wire media/rss adapters, event-driven layout push

- React SPA: dashboard, data sources CRUD, widgets CRUD, layout builder,
  presets. TanStack Router + Query, shadcn/ui, Vite proxy to :3000
- wire media + rss adapters into polling loop, remove xtb source type
- media adapter: read username/password from headers, proper subsonic auth
- event handler: subscribe to LayoutChanged, push screen update to clients
- fix clippy warnings across workspace (Default impls, collapsible ifs,
  redundant closures, is_none_or, unused imports)
This commit is contained in:
2026-06-19 00:12:42 +02:00
parent 21c08911df
commit 26ebfad3a2
175 changed files with 12338 additions and 801 deletions

View File

@@ -1,10 +1,9 @@
use domain::{
ConfigRepository, DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, WidgetConfig,
WidgetId,
};
use std::collections::HashMap;
use std::sync::RwLock;
use domain::{
ConfigRepository,
DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId,
WidgetConfig, WidgetId,
};
#[derive(Debug, thiserror::Error)]
pub enum MemoryConfigError {
@@ -19,8 +18,8 @@ pub struct MemoryConfigStore {
presets: RwLock<HashMap<LayoutPresetId, LayoutPreset>>,
}
impl MemoryConfigStore {
pub fn new() -> Self {
impl Default for MemoryConfigStore {
fn default() -> Self {
Self {
widgets: RwLock::new(HashMap::new()),
data_sources: RwLock::new(HashMap::new()),
@@ -30,82 +29,130 @@ impl MemoryConfigStore {
}
}
impl MemoryConfigStore {
pub fn new() -> Self {
Self::default()
}
}
impl ConfigRepository for MemoryConfigStore {
type Error = MemoryConfigError;
async fn get_widget(&self, id: WidgetId) -> Result<Option<WidgetConfig>, Self::Error> {
let guard = self.widgets.read().map_err(|_| MemoryConfigError::LockPoisoned)?;
let guard = self
.widgets
.read()
.map_err(|_| MemoryConfigError::LockPoisoned)?;
Ok(guard.get(&id).cloned())
}
async fn list_widgets(&self) -> Result<Vec<WidgetConfig>, Self::Error> {
let guard = self.widgets.read().map_err(|_| MemoryConfigError::LockPoisoned)?;
let guard = self
.widgets
.read()
.map_err(|_| MemoryConfigError::LockPoisoned)?;
Ok(guard.values().cloned().collect())
}
async fn save_widget(&self, config: &WidgetConfig) -> Result<(), Self::Error> {
let mut guard = self.widgets.write().map_err(|_| MemoryConfigError::LockPoisoned)?;
let mut guard = self
.widgets
.write()
.map_err(|_| MemoryConfigError::LockPoisoned)?;
guard.insert(config.id, config.clone());
Ok(())
}
async fn delete_widget(&self, id: WidgetId) -> Result<(), Self::Error> {
let mut guard = self.widgets.write().map_err(|_| MemoryConfigError::LockPoisoned)?;
let mut guard = self
.widgets
.write()
.map_err(|_| MemoryConfigError::LockPoisoned)?;
guard.remove(&id);
Ok(())
}
async fn get_data_source(&self, id: DataSourceId) -> Result<Option<DataSource>, Self::Error> {
let guard = self.data_sources.read().map_err(|_| MemoryConfigError::LockPoisoned)?;
let guard = self
.data_sources
.read()
.map_err(|_| MemoryConfigError::LockPoisoned)?;
Ok(guard.get(&id).cloned())
}
async fn list_data_sources(&self) -> Result<Vec<DataSource>, Self::Error> {
let guard = self.data_sources.read().map_err(|_| MemoryConfigError::LockPoisoned)?;
let guard = self
.data_sources
.read()
.map_err(|_| MemoryConfigError::LockPoisoned)?;
Ok(guard.values().cloned().collect())
}
async fn save_data_source(&self, source: &DataSource) -> Result<(), Self::Error> {
let mut guard = self.data_sources.write().map_err(|_| MemoryConfigError::LockPoisoned)?;
let mut guard = self
.data_sources
.write()
.map_err(|_| MemoryConfigError::LockPoisoned)?;
guard.insert(source.id, source.clone());
Ok(())
}
async fn delete_data_source(&self, id: DataSourceId) -> Result<(), Self::Error> {
let mut guard = self.data_sources.write().map_err(|_| MemoryConfigError::LockPoisoned)?;
let mut guard = self
.data_sources
.write()
.map_err(|_| MemoryConfigError::LockPoisoned)?;
guard.remove(&id);
Ok(())
}
async fn get_layout(&self) -> Result<Option<Layout>, Self::Error> {
let guard = self.layout.read().map_err(|_| MemoryConfigError::LockPoisoned)?;
let guard = self
.layout
.read()
.map_err(|_| MemoryConfigError::LockPoisoned)?;
Ok(guard.clone())
}
async fn save_layout(&self, layout: &Layout) -> Result<(), Self::Error> {
let mut guard = self.layout.write().map_err(|_| MemoryConfigError::LockPoisoned)?;
let mut guard = self
.layout
.write()
.map_err(|_| MemoryConfigError::LockPoisoned)?;
*guard = Some(layout.clone());
Ok(())
}
async fn get_preset(&self, id: LayoutPresetId) -> Result<Option<LayoutPreset>, Self::Error> {
let guard = self.presets.read().map_err(|_| MemoryConfigError::LockPoisoned)?;
let guard = self
.presets
.read()
.map_err(|_| MemoryConfigError::LockPoisoned)?;
Ok(guard.get(&id).cloned())
}
async fn list_presets(&self) -> Result<Vec<LayoutPreset>, Self::Error> {
let guard = self.presets.read().map_err(|_| MemoryConfigError::LockPoisoned)?;
let guard = self
.presets
.read()
.map_err(|_| MemoryConfigError::LockPoisoned)?;
Ok(guard.values().cloned().collect())
}
async fn save_preset(&self, preset: &LayoutPreset) -> Result<(), Self::Error> {
let mut guard = self.presets.write().map_err(|_| MemoryConfigError::LockPoisoned)?;
let mut guard = self
.presets
.write()
.map_err(|_| MemoryConfigError::LockPoisoned)?;
guard.insert(preset.id, preset.clone());
Ok(())
}
async fn delete_preset(&self, id: LayoutPresetId) -> Result<(), Self::Error> {
let mut guard = self.presets.write().map_err(|_| MemoryConfigError::LockPoisoned)?;
let mut guard = self
.presets
.write()
.map_err(|_| MemoryConfigError::LockPoisoned)?;
guard.remove(&id);
Ok(())
}

View File

@@ -1,6 +1,6 @@
pub mod error;
mod serialization;
mod repository;
mod serialization;
use sqlx::SqlitePool;
@@ -27,8 +27,10 @@ impl SqliteConfigStore {
data_source_id INTEGER NOT NULL,
mappings TEXT NOT NULL,
max_data_size INTEGER NOT NULL
)"
).execute(&self.pool).await?;
)",
)
.execute(&self.pool)
.await?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS data_sources (
@@ -37,23 +39,29 @@ impl SqliteConfigStore {
source_type TEXT NOT NULL,
poll_interval_secs INTEGER NOT NULL,
config TEXT NOT NULL
)"
).execute(&self.pool).await?;
)",
)
.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?;
)",
)
.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?;
)",
)
.execute(&self.pool)
.await?;
Ok(())
}

View File

@@ -1,10 +1,13 @@
use domain::{DataSource, DataSourceId};
use crate::SqliteConfigStore;
use crate::error::SqliteConfigError;
use crate::serialization::data_source as ser;
use domain::{DataSource, DataSourceId};
impl SqliteConfigStore {
pub(crate) async fn get_data_source_impl(&self, id: DataSourceId) -> Result<Option<DataSource>, SqliteConfigError> {
pub(crate) async fn get_data_source_impl(
&self,
id: DataSourceId,
) -> Result<Option<DataSource>, SqliteConfigError> {
let row = sqlx::query("SELECT * FROM data_sources WHERE id = ?")
.bind(id as i64)
.fetch_optional(&self.pool)
@@ -17,16 +20,21 @@ impl SqliteConfigStore {
}
}
pub(crate) async fn list_data_sources_impl(&self) -> Result<Vec<DataSource>, SqliteConfigError> {
pub(crate) async fn list_data_sources_impl(
&self,
) -> Result<Vec<DataSource>, 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()
rows.iter().map(ser::data_source_from_row).collect()
}
pub(crate) async fn save_data_source_impl(&self, source: &DataSource) -> Result<(), SqliteConfigError> {
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);
@@ -46,7 +54,10 @@ impl SqliteConfigStore {
Ok(())
}
pub(crate) async fn delete_data_source_impl(&self, id: DataSourceId) -> Result<(), SqliteConfigError> {
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)

View File

@@ -1,8 +1,8 @@
use sqlx::Row;
use domain::Layout;
use crate::SqliteConfigStore;
use crate::error::SqliteConfigError;
use crate::serialization::layout as ser;
use domain::Layout;
use sqlx::Row;
impl SqliteConfigStore {
pub(crate) async fn get_layout_impl(&self) -> Result<Option<Layout>, SqliteConfigError> {
@@ -23,13 +23,11 @@ impl SqliteConfigStore {
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)?;
sqlx::query("INSERT OR REPLACE INTO layout (id, data) VALUES (1, ?)")
.bind(&json)
.execute(&self.pool)
.await
.map_err(SqliteConfigError::Sql)?;
Ok(())
}

View File

@@ -1,16 +1,14 @@
mod widgets;
mod data_sources;
mod layout;
mod presets;
mod widgets;
use domain::{
ConfigRepository,
DataSource, DataSourceId,
Layout, LayoutPreset, LayoutPresetId,
WidgetConfig, WidgetId,
};
use crate::SqliteConfigStore;
use crate::error::SqliteConfigError;
use domain::{
ConfigRepository, DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, WidgetConfig,
WidgetId,
};
impl ConfigRepository for SqliteConfigStore {
type Error = SqliteConfigError;

View File

@@ -1,10 +1,13 @@
use domain::{LayoutPreset, LayoutPresetId};
use crate::SqliteConfigStore;
use crate::error::SqliteConfigError;
use crate::serialization::{layout as layout_ser, preset as ser};
use domain::{LayoutPreset, LayoutPresetId};
impl SqliteConfigStore {
pub(crate) async fn get_preset_impl(&self, id: LayoutPresetId) -> Result<Option<LayoutPreset>, SqliteConfigError> {
pub(crate) async fn get_preset_impl(
&self,
id: LayoutPresetId,
) -> Result<Option<LayoutPreset>, SqliteConfigError> {
let row = sqlx::query("SELECT * FROM presets WHERE id = ?")
.bind(id as i64)
.fetch_optional(&self.pool)
@@ -23,26 +26,30 @@ impl SqliteConfigStore {
.await
.map_err(SqliteConfigError::Sql)?;
rows.iter().map(|r| ser::preset_from_row(r)).collect()
rows.iter().map(ser::preset_from_row).collect()
}
pub(crate) async fn save_preset_impl(&self, preset: &LayoutPreset) -> Result<(), SqliteConfigError> {
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)?;
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> {
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)

View File

@@ -1,10 +1,13 @@
use domain::{WidgetConfig, WidgetId};
use crate::SqliteConfigStore;
use crate::error::SqliteConfigError;
use crate::serialization::widget as ser;
use domain::{WidgetConfig, WidgetId};
impl SqliteConfigStore {
pub(crate) async fn get_widget_impl(&self, id: WidgetId) -> Result<Option<WidgetConfig>, SqliteConfigError> {
pub(crate) async fn get_widget_impl(
&self,
id: WidgetId,
) -> Result<Option<WidgetConfig>, SqliteConfigError> {
let row = sqlx::query("SELECT * FROM widgets WHERE id = ?")
.bind(id as i64)
.fetch_optional(&self.pool)
@@ -23,10 +26,13 @@ impl SqliteConfigStore {
.await
.map_err(SqliteConfigError::Sql)?;
rows.iter().map(|r| ser::widget_from_row(r)).collect()
rows.iter().map(ser::widget_from_row).collect()
}
pub(crate) async fn save_widget_impl(&self, config: &WidgetConfig) -> Result<(), SqliteConfigError> {
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);

View File

@@ -1,14 +1,13 @@
use std::time::Duration;
use crate::error::SqliteConfigError;
use domain::{DataSource, DataSourceConfig, DataSourceType};
use sqlx::Row;
use sqlx::sqlite::SqliteRow;
use domain::{DataSource, DataSourceConfig, DataSourceType};
use crate::error::SqliteConfigError;
use std::time::Duration;
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",
@@ -19,11 +18,12 @@ fn data_source_type_from_str(s: &str) -> Result<DataSourceType, SqliteConfigErro
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}"))),
_ => Err(SqliteConfigError::Serialization(format!(
"unknown source type: {s}"
))),
}
}
@@ -37,20 +37,27 @@ pub fn data_source_config_to_json(config: &DataSourceConfig) -> Result<String, S
}
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 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(),
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 })
Ok(DataSourceConfig {
url,
headers,
api_key,
})
}
pub fn data_source_from_row(row: &SqliteRow) -> Result<DataSource, SqliteConfigError> {

View File

@@ -1,5 +1,5 @@
use domain::{ContainerNode, Direction, Layout, LayoutChild, LayoutNode, Sizing};
use crate::error::SqliteConfigError;
use domain::{ContainerNode, Direction, Layout, LayoutChild, LayoutNode, Sizing};
pub fn layout_to_json(layout: &Layout) -> Result<String, SqliteConfigError> {
let v = node_to_json(&layout.root);
@@ -7,8 +7,8 @@ pub fn layout_to_json(layout: &Layout) -> Result<String, SqliteConfigError> {
}
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 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 })
}
@@ -17,16 +17,20 @@ 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),
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();
.collect();
serde_json::json!({
"type": "container",
@@ -44,25 +48,44 @@ fn node_from_json(v: &serde_json::Value) -> Result<LayoutNode, SqliteConfigError
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;
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"))? {
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()
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),
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"])?;
@@ -70,7 +93,12 @@ fn node_from_json(v: &serde_json::Value) -> Result<LayoutNode, SqliteConfigError
})
.collect::<Result<Vec<_>, _>>()?;
Ok(LayoutNode::Container(ContainerNode { direction, gap, padding, children }))
Ok(LayoutNode::Container(ContainerNode {
direction,
gap,
padding,
children,
}))
}
t => Err(err(&format!("unknown node type: {t}"))),
}

View File

@@ -1,4 +1,4 @@
pub mod widget;
pub mod data_source;
pub mod layout;
pub mod preset;
pub mod widget;

View File

@@ -1,8 +1,8 @@
use super::layout::layout_from_json;
use crate::error::SqliteConfigError;
use domain::LayoutPreset;
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<LayoutPreset, SqliteConfigError> {
let id: i64 = row.get("id");

View File

@@ -1,7 +1,7 @@
use crate::error::SqliteConfigError;
use domain::{DisplayHint, KeyMapping, WidgetConfig};
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 {
@@ -16,32 +16,44 @@ fn display_hint_from_str(s: &str) -> Result<DisplayHint, SqliteConfigError> {
"icon_value" => Ok(DisplayHint::IconValue),
"text_block" => Ok(DisplayHint::TextBlock),
"key_value" => Ok(DisplayHint::KeyValue),
_ => Err(SqliteConfigError::Serialization(format!("unknown display hint: {s}"))),
_ => Err(SqliteConfigError::Serialization(format!(
"unknown display hint: {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,
let entries: Vec<serde_json::Value> = mappings
.iter()
.map(|m| {
serde_json::json!({
"source_path": m.source_path,
"target_key": m.target_key,
})
})
}).collect();
.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()))?;
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(),
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()
.collect()
}
pub fn widget_from_row(row: &SqliteRow) -> Result<WidgetConfig, SqliteConfigError> {

View File

@@ -1,11 +1,9 @@
use std::time::Duration;
use domain::{
ConfigRepository, DisplayHint, KeyMapping, WidgetConfig,
DataSource, DataSourceConfig, DataSourceType,
Layout, LayoutNode, ContainerNode, LayoutChild, Direction, Sizing,
LayoutPreset,
};
use config_sqlite::SqliteConfigStore;
use domain::{
ConfigRepository, ContainerNode, DataSource, DataSourceConfig, DataSourceType, Direction,
DisplayHint, KeyMapping, Layout, LayoutChild, LayoutNode, LayoutPreset, Sizing, WidgetConfig,
};
use std::time::Duration;
async fn test_store() -> SqliteConfigStore {
SqliteConfigStore::new("sqlite::memory:").await.unwrap()
@@ -18,8 +16,14 @@ fn weather_widget() -> WidgetConfig {
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() },
KeyMapping {
source_path: "$.temp".into(),
target_key: "temperature".into(),
},
KeyMapping {
source_path: "$.icon".into(),
target_key: "icon".into(),
},
],
max_data_size: 2048,
}
@@ -46,8 +50,14 @@ fn test_layout() -> Layout {
gap: 4,
padding: 2,
children: vec![
LayoutChild { sizing: Sizing::Flex(1), node: LayoutNode::Leaf(1) },
LayoutChild { sizing: Sizing::Fixed(80), node: LayoutNode::Leaf(2) },
LayoutChild {
sizing: Sizing::Flex(1),
node: LayoutNode::Leaf(1),
},
LayoutChild {
sizing: Sizing::Fixed(80),
node: LayoutNode::Leaf(2),
},
],
}),
}
@@ -78,14 +88,17 @@ async fn get_nonexistent_widget_returns_none() {
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();
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);
@@ -172,12 +185,22 @@ async fn save_and_retrieve_preset() {
#[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();
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);

View File

@@ -1,5 +1,6 @@
use client_domain::{BoundingBox, DisplayPort};
#[derive(Default)]
pub struct TerminalDisplay;
impl TerminalDisplay {
@@ -12,12 +13,24 @@ impl DisplayPort for TerminalDisplay {
type Error = std::io::Error;
fn clear_region(&mut self, bounds: BoundingBox) -> Result<(), Self::Error> {
println!("[CLEAR] ({}, {}) {}x{}", bounds.x, bounds.y, bounds.width, bounds.height);
println!(
"[CLEAR] ({}, {}) {}x{}",
bounds.x, bounds.y, bounds.width, bounds.height
);
Ok(())
}
fn draw_text(&mut self, text: &str, x: u16, y: u16, bounds: BoundingBox) -> Result<(), Self::Error> {
println!("[TEXT] ({x}, {y}) in {}x{}: \"{text}\"", bounds.width, bounds.height);
fn draw_text(
&mut self,
text: &str,
x: u16,
y: u16,
bounds: BoundingBox,
) -> Result<(), Self::Error> {
println!(
"[TEXT] ({x}, {y}) in {}x{}: \"{text}\"",
bounds.width, bounds.height
);
Ok(())
}
@@ -27,7 +40,10 @@ impl DisplayPort for TerminalDisplay {
}
fn fill_background(&mut self, bounds: BoundingBox) -> Result<(), Self::Error> {
println!("[BG] ({}, {}) {}x{}", bounds.x, bounds.y, bounds.width, bounds.height);
println!(
"[BG] ({}, {}) {}x{}",
bounds.x, bounds.y, bounds.width, bounds.height
);
Ok(())
}

View File

@@ -1,9 +1,9 @@
mod routes;
use std::sync::Arc;
use axum::Router;
use tower_http::cors::CorsLayer;
use domain::{ConfigRepository, EventPublisher};
use std::sync::Arc;
use tower_http::cors::CorsLayer;
pub struct AppState<C, E> {
pub config: Arc<C>,

View File

@@ -1,54 +1,107 @@
use crate::AppState;
use api_types::DataSourceDto;
use application::ConfigService;
use axum::{
extract::{Path, State},
http::StatusCode,
response::Json,
};
use domain::{ConfigRepository, EventPublisher};
use application::ConfigService;
use crate::AppState;
use api_types::DataSourceDto;
type S<C, E> = State<AppState<C, E>>;
pub 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,
pub 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)?;
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<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,
pub 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)?;
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<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,
pub 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 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}")))?;
svc.create_data_source(source)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
Ok(StatusCode::CREATED)
}
pub 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,
pub 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 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}")))?;
svc.update_data_source(source)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
Ok(StatusCode::OK)
}
pub 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,
pub 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)?;
svc.delete_data_source(id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(StatusCode::NO_CONTENT)
}

View File

@@ -1,27 +1,42 @@
use axum::{
extract::State,
http::StatusCode,
response::Json,
};
use domain::{ConfigRepository, EventPublisher};
use application::ConfigService;
use crate::AppState;
use api_types::LayoutDto;
use application::ConfigService;
use axum::{extract::State, http::StatusCode, response::Json};
use domain::{ConfigRepository, EventPublisher};
type S<C, E> = State<AppState<C, E>>;
pub 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,
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)?;
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<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,
pub 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 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}")))?;
svc.update_layout(layout)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
Ok(StatusCode::OK)
}

View File

@@ -1,12 +1,12 @@
mod widgets;
mod data_sources;
mod layout;
mod presets;
mod widgets;
use axum::Router;
use axum::routing::{get, post, put, delete};
use domain::{ConfigRepository, EventPublisher};
use crate::AppState;
use axum::Router;
use axum::routing::{get, post};
use domain::{ConfigRepository, EventPublisher};
pub fn api_routes<C, E>() -> Router<AppState<C, E>>
where
@@ -16,12 +16,38 @@ where
E::Error: std::fmt::Debug + Send,
{
Router::new()
.route("/widgets", get(widgets::list_widgets::<C, E>).post(widgets::create_widget::<C, E>))
.route("/widgets/{id}", get(widgets::get_widget::<C, E>).put(widgets::update_widget::<C, E>).delete(widgets::delete_widget::<C, E>))
.route("/data-sources", get(data_sources::list_data_sources::<C, E>).post(data_sources::create_data_source::<C, E>))
.route("/data-sources/{id}", get(data_sources::get_data_source::<C, E>).put(data_sources::update_data_source::<C, E>).delete(data_sources::delete_data_source::<C, E>))
.route("/layout", get(layout::get_layout::<C, E>).put(layout::update_layout::<C, E>))
.route("/presets", get(presets::list_presets::<C, E>).post(presets::create_preset::<C, E>))
.route("/presets/{id}", get(presets::get_preset::<C, E>).delete(presets::delete_preset::<C, E>))
.route(
"/widgets",
get(widgets::list_widgets::<C, E>).post(widgets::create_widget::<C, E>),
)
.route(
"/widgets/{id}",
get(widgets::get_widget::<C, E>)
.put(widgets::update_widget::<C, E>)
.delete(widgets::delete_widget::<C, E>),
)
.route(
"/data-sources",
get(data_sources::list_data_sources::<C, E>)
.post(data_sources::create_data_source::<C, E>),
)
.route(
"/data-sources/{id}",
get(data_sources::get_data_source::<C, E>)
.put(data_sources::update_data_source::<C, E>)
.delete(data_sources::delete_data_source::<C, E>),
)
.route(
"/layout",
get(layout::get_layout::<C, E>).put(layout::update_layout::<C, E>),
)
.route(
"/presets",
get(presets::list_presets::<C, E>).post(presets::create_preset::<C, E>),
)
.route(
"/presets/{id}",
get(presets::get_preset::<C, E>).delete(presets::delete_preset::<C, E>),
)
.route("/presets/{id}/load", post(presets::load_preset::<C, E>))
}

View File

@@ -1,53 +1,101 @@
use crate::AppState;
use api_types::{CreatePresetDto, PresetDto};
use application::ConfigService;
use axum::{
extract::{Path, State},
http::StatusCode,
response::Json,
};
use domain::{ConfigRepository, EventPublisher};
use application::ConfigService;
use crate::AppState;
use api_types::{PresetDto, CreatePresetDto};
type S<C, E> = State<AppState<C, E>>;
pub 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,
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)?;
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<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,
pub 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)?;
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<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,
pub 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 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}")))?;
svc.save_preset(preset)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
Ok(StatusCode::CREATED)
}
pub 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,
pub 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)?;
svc.delete_preset(id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(StatusCode::NO_CONTENT)
}
pub 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,
pub 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}")))?;
svc.load_preset(id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
Ok(StatusCode::OK)
}

View File

@@ -1,54 +1,105 @@
use crate::AppState;
use api_types::{CreateWidgetDto, WidgetDto};
use application::ConfigService;
use axum::{
extract::{Path, State},
http::StatusCode,
response::Json,
};
use domain::{ConfigRepository, EventPublisher};
use application::ConfigService;
use crate::AppState;
use api_types::{WidgetDto, CreateWidgetDto};
type S<C, E> = State<AppState<C, E>>;
pub 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,
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)?;
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<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,
pub 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)?;
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<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,
pub 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 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}")))?;
svc.create_widget(widget)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
Ok(StatusCode::CREATED)
}
pub 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,
pub 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 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}")))?;
svc.update_widget(widget)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
Ok(StatusCode::OK)
}
pub 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,
pub 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)?;
svc.delete_widget(id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(StatusCode::NO_CONTENT)
}

View File

@@ -1,10 +1,10 @@
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};
use std::sync::Arc;
use tcp_server::TcpEventBus;
use tower::ServiceExt;
fn test_app() -> axum::Router {
let config = Arc::new(MemoryConfigStore::new());
@@ -38,13 +38,22 @@ async fn create_and_get_widget() {
"mappings": [{"source_path": "$.temp", "target_key": "temperature"}]
}"#;
let resp = app.clone().oneshot(json_request("POST", "/api/widgets", Some(body))).await.unwrap();
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();
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 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");
@@ -58,11 +67,22 @@ async fn list_widgets() {
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();
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 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);
}
@@ -71,13 +91,24 @@ async fn list_widgets() {
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 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();
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();
let resp = app
.oneshot(json_request("GET", "/api/widgets/1", None))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
@@ -95,13 +126,22 @@ async fn create_and_get_data_source() {
"headers": []
}"#;
let resp = app.clone().oneshot(json_request("POST", "/api/data-sources", Some(body))).await.unwrap();
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();
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 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);
@@ -124,13 +164,22 @@ async fn update_and_get_layout() {
}
}"#;
let resp = app.clone().oneshot(json_request("PUT", "/api/layout", Some(body))).await.unwrap();
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();
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 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");
@@ -141,9 +190,16 @@ async fn update_and_get_layout() {
async fn get_nonexistent_returns_404() {
let app = test_app();
let resp = app.clone().oneshot(json_request("GET", "/api/widgets/99", None)).await.unwrap();
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();
let resp = app
.oneshot(json_request("GET", "/api/data-sources/99", None))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

View File

@@ -14,26 +14,32 @@ pub enum HttpJsonError {
Parse(String),
}
impl HttpJsonAdapter {
pub fn new() -> Self {
impl Default for HttpJsonAdapter {
fn default() -> Self {
Self {
client: reqwest::Client::new(),
}
}
}
impl HttpJsonAdapter {
pub fn new() -> Self {
Self::default()
}
}
fn json_to_value(json: serde_json::Value) -> Value {
match json {
serde_json::Value::Null => Value::Null,
serde_json::Value::Bool(b) => Value::Bool(b),
serde_json::Value::Number(n) => Value::Number(n.as_f64().unwrap_or(0.0)),
serde_json::Value::String(s) => Value::String(s),
serde_json::Value::Array(arr) => {
Value::Array(arr.into_iter().map(json_to_value).collect())
}
serde_json::Value::Object(map) => {
Value::Object(map.into_iter().map(|(k, v)| (k, json_to_value(v))).collect())
}
serde_json::Value::Array(arr) => Value::Array(arr.into_iter().map(json_to_value).collect()),
serde_json::Value::Object(map) => Value::Object(
map.into_iter()
.map(|(k, v)| (k, json_to_value(v)))
.collect(),
),
}
}

View File

@@ -1,19 +1,23 @@
use std::time::Duration;
use axum::{Router, routing::get, response::Json};
use axum::{Router, response::Json, routing::get};
use domain::{DataSource, DataSourceConfig, DataSourcePort, DataSourceType, Value};
use http_json::HttpJsonAdapter;
use std::time::Duration;
async fn start_fake_api() -> String {
let app = Router::new()
.route("/weather", get(|| async {
Json(serde_json::json!({
"main": {"temp": 5.4, "humidity": 80},
"weather": [{"icon": "cloud_rain"}]
}))
}))
.route("/simple", get(|| async {
Json(serde_json::json!({"value": "hello"}))
}))
.route(
"/weather",
get(|| async {
Json(serde_json::json!({
"main": {"temp": 5.4, "humidity": 80},
"weather": [{"icon": "cloud_rain"}]
}))
}),
)
.route(
"/simple",
get(|| async { Json(serde_json::json!({"value": "hello"})) }),
)
.route("/not-json", get(|| async { "plain text" }));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
@@ -46,10 +50,7 @@ async fn polls_url_and_returns_nested_json_as_value() {
let result = adapter.poll(&source).await.unwrap();
assert_eq!(
result.get_path("$.main.temp"),
Some(&Value::Number(5.4))
);
assert_eq!(result.get_path("$.main.temp"), Some(&Value::Number(5.4)));
assert_eq!(
result.get_path("$.main.humidity"),
Some(&Value::Number(80.0))

View File

@@ -8,6 +8,8 @@ domain.workspace = true
reqwest.workspace = true
serde_json.workspace = true
thiserror.workspace = true
md5 = "0.7"
fastrand = "2"
[dev-dependencies]
tokio.workspace = true

View File

@@ -4,6 +4,8 @@ pub enum MediaError {
Request(#[from] reqwest::Error),
#[error("no url configured")]
NoUrl,
#[error("missing field in headers: {0}")]
MissingField(&'static str),
#[error("parse: {0}")]
Parse(String),
}

View File

@@ -2,33 +2,61 @@ mod error;
pub use error::MediaError;
use std::collections::BTreeMap;
use domain::{DataSource, DataSourcePort, Value};
use std::collections::BTreeMap;
pub struct MediaAdapter {
client: reqwest::Client,
}
impl MediaAdapter {
pub fn new() -> Self {
impl Default for MediaAdapter {
fn default() -> Self {
Self {
client: reqwest::Client::new(),
}
}
}
impl MediaAdapter {
pub fn new() -> Self {
Self::default()
}
}
fn find_header<'a>(headers: &'a [(String, String)], key: &str) -> Option<&'a str> {
headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(key))
.map(|(_, v)| v.as_str())
}
fn subsonic_token(password: &str, salt: &str) -> String {
format!("{:x}", md5::compute(format!("{password}{salt}")))
}
impl DataSourcePort for MediaAdapter {
type Error = MediaError;
async fn poll(&self, source: &DataSource) -> Result<Value, Self::Error> {
let base_url = source.config.url.as_ref().ok_or(MediaError::NoUrl)?;
let api_key = source.config.api_key.as_deref().unwrap_or("");
let username = find_header(&source.config.headers, "username")
.ok_or(MediaError::MissingField("username"))?;
let password = find_header(&source.config.headers, "password")
.ok_or(MediaError::MissingField("password"))?;
let salt: String = (0..12).map(|_| fastrand::alphanumeric()).collect();
let token = subsonic_token(password, &salt);
let url = format!(
"{base_url}/rest/getNowPlaying.view?u=kframe&t={api_key}&s=salt&v=1.16.1&c=kframe&f=json"
"{base_url}/rest/getNowPlaying.view?u={username}&t={token}&s={salt}&v=1.16.1&c=kframe&f=json"
);
let resp = self.client.get(&url).send().await.map_err(MediaError::Request)?;
let resp = self
.client
.get(&url)
.send()
.await
.map_err(MediaError::Request)?;
let json: serde_json::Value = resp.json().await.map_err(MediaError::Request)?;
let entries = json["subsonic-response"]["nowPlaying"]["entry"]
@@ -45,15 +73,18 @@ impl DataSourcePort for MediaAdapter {
let entry = &entries[0];
let mut result = BTreeMap::new();
result.insert("playing".into(), Value::Bool(true));
result.insert("title".into(), Value::String(
entry["title"].as_str().unwrap_or("Unknown").into()
));
result.insert("artist".into(), Value::String(
entry["artist"].as_str().unwrap_or("Unknown").into()
));
result.insert("album".into(), Value::String(
entry["album"].as_str().unwrap_or("Unknown").into()
));
result.insert(
"title".into(),
Value::String(entry["title"].as_str().unwrap_or("Unknown").into()),
);
result.insert(
"artist".into(),
Value::String(entry["artist"].as_str().unwrap_or("Unknown").into()),
);
result.insert(
"album".into(),
Value::String(entry["album"].as_str().unwrap_or("Unknown").into()),
);
if let Some(duration) = entry["duration"].as_u64() {
result.insert("duration".into(), Value::Number(duration as f64));

View File

@@ -1,6 +1,6 @@
use std::time::Duration;
use domain::{DataSource, DataSourceConfig, DataSourcePort, DataSourceType, Value};
use media_adapter::MediaAdapter;
use std::time::Duration;
fn subsonic_response(playing: bool) -> serde_json::Value {
if playing {
@@ -28,10 +28,10 @@ fn subsonic_response(playing: bool) -> serde_json::Value {
}
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 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();
@@ -47,8 +47,11 @@ fn make_source(url: String) -> DataSource {
poll_interval: Duration::from_secs(5),
config: DataSourceConfig {
url: Some(url),
headers: vec![],
api_key: Some("testtoken".into()),
headers: vec![
("username".into(), "test".into()),
("password".into(), "testpass".into()),
],
api_key: None,
},
}
}
@@ -62,8 +65,14 @@ async fn returns_now_playing_info() {
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())));
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]

View File

@@ -10,21 +10,32 @@ pub struct RssAdapter {
client: reqwest::Client,
}
impl RssAdapter {
pub fn new() -> Self {
impl Default for RssAdapter {
fn default() -> Self {
Self {
client: reqwest::Client::new(),
}
}
}
impl RssAdapter {
pub fn new() -> Self {
Self::default()
}
}
impl DataSourcePort for RssAdapter {
type Error = RssError;
async fn poll(&self, source: &DataSource) -> Result<Value, Self::Error> {
let url = source.config.url.as_ref().ok_or(RssError::NoUrl)?;
let resp = self.client.get(url).send().await.map_err(RssError::Request)?;
let resp = self
.client
.get(url)
.send()
.await
.map_err(RssError::Request)?;
let xml = resp.text().await.map_err(RssError::Request)?;
parser::parse_rss(&xml)

View File

@@ -29,10 +29,10 @@ pub fn parse_rss(xml: &str) -> Result<Value, RssError> {
}
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));
}
if tag == "item"
&& let Some(item) = current_item.take()
{
items.push(Value::Object(item));
}
current_tag.clear();
}
@@ -52,10 +52,10 @@ pub fn parse_rss(xml: &str) -> Result<Value, RssError> {
}
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));
}
if !current_tag.is_empty()
&& let Some(item) = current_item.as_mut()
{
item.insert(current_tag.clone(), Value::String(text));
}
}
Ok(Event::Eof) => break,

View File

@@ -1,5 +1,5 @@
use domain::Value;
use rss_adapter::{parse_rss, RssError};
use rss_adapter::{RssError, parse_rss};
const SAMPLE_RSS: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
@@ -23,8 +23,20 @@ const SAMPLE_RSS: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
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())));
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()))
);
}

View File

@@ -1,8 +1,8 @@
use client_domain::NetworkPort;
use protocol::MAX_FRAME_SIZE;
use std::io::{Read, Write};
use std::net::TcpStream;
use std::time::Duration;
use client_domain::NetworkPort;
use protocol::MAX_FRAME_SIZE;
#[derive(Debug, thiserror::Error)]
pub enum TcpClientError {
@@ -14,13 +14,14 @@ pub enum TcpClientError {
FrameTooLarge(usize),
}
#[derive(Default)]
pub struct StdTcpClient {
stream: Option<TcpStream>,
}
impl StdTcpClient {
pub fn new() -> Self {
Self { stream: None }
Self::default()
}
}
@@ -30,7 +31,9 @@ impl NetworkPort for StdTcpClient {
fn connect(&mut self, addr: &str) -> Result<(), Self::Error> {
let stream = TcpStream::connect(addr).map_err(TcpClientError::Io)?;
stream.set_nonblocking(true).map_err(TcpClientError::Io)?;
stream.set_read_timeout(Some(Duration::from_millis(10))).map_err(TcpClientError::Io)?;
stream
.set_read_timeout(Some(Duration::from_millis(10)))
.map_err(TcpClientError::Io)?;
self.stream = Some(stream);
Ok(())
}
@@ -63,7 +66,9 @@ impl NetworkPort for StdTcpClient {
let mut payload = vec![0u8; len];
stream.set_nonblocking(false).map_err(TcpClientError::Io)?;
stream.read_exact(&mut payload).map_err(TcpClientError::Io)?;
stream
.read_exact(&mut payload)
.map_err(TcpClientError::Io)?;
stream.set_nonblocking(true).map_err(TcpClientError::Io)?;
Ok(Some(payload))

View File

@@ -1,12 +1,7 @@
use tokio::sync::broadcast;
use domain::{
BroadcastPort, Layout, WidgetId, WidgetState,
};
use protocol::{
ServerMessage, WidgetDescriptor, WireDisplayHint, WireLayoutNode,
encode,
};
use crate::error::TcpServerError;
use domain::{BroadcastPort, Layout, WidgetId, WidgetState};
use protocol::{ServerMessage, WidgetDescriptor, WireDisplayHint, WireLayoutNode, encode};
use tokio::sync::broadcast;
pub struct TcpBroadcaster {
tx: broadcast::Sender<Vec<u8>>,
@@ -37,13 +32,14 @@ impl BroadcastPort for TcpBroadcaster {
widgets: &[(WidgetId, WidgetState)],
) -> Result<(), Self::Error> {
let wire_layout: WireLayoutNode = (&layout.root).into();
let wire_widgets: Vec<WidgetDescriptor> = widgets.iter().map(|(id, state)| {
WidgetDescriptor {
let wire_widgets: Vec<WidgetDescriptor> = widgets
.iter()
.map(|(id, state)| WidgetDescriptor {
id: *id,
display_hint: WireDisplayHint::IconValue,
state: state.into(),
}
}).collect();
})
.collect();
let msg = ServerMessage::ScreenUpdate {
layout: wire_layout,
@@ -58,13 +54,14 @@ impl BroadcastPort for TcpBroadcaster {
&self,
updates: &[(WidgetId, WidgetState)],
) -> Result<(), Self::Error> {
let wire_widgets: Vec<WidgetDescriptor> = updates.iter().map(|(id, state)| {
WidgetDescriptor {
let wire_widgets: Vec<WidgetDescriptor> = updates
.iter()
.map(|(id, state)| WidgetDescriptor {
id: *id,
display_hint: WireDisplayHint::IconValue,
state: state.into(),
}
}).collect();
})
.collect();
let msg = ServerMessage::DataUpdate {
widgets: wire_widgets,

View File

@@ -1,6 +1,6 @@
use tokio::sync::broadcast;
use domain::{EventPublisher, DomainEvent};
use crate::error::TcpServerError;
use domain::{DomainEvent, EventPublisher};
use tokio::sync::broadcast;
pub struct TcpEventBus {
tx: broadcast::Sender<DomainEvent>,

View File

@@ -1,9 +1,9 @@
mod error;
mod broadcaster;
mod error;
mod event_bus;
mod server;
pub use error::TcpServerError;
pub use broadcaster::TcpBroadcaster;
pub use error::TcpServerError;
pub use event_bus::TcpEventBus;
pub use server::run_tcp_server;

View File

@@ -1,10 +1,10 @@
use std::sync::Arc;
use tokio::net::TcpListener;
use tokio::sync::broadcast;
use tokio::io::AsyncWriteExt;
use tracing::{info, warn};
use crate::broadcaster::TcpBroadcaster;
use crate::error::TcpServerError;
use std::sync::Arc;
use tokio::io::AsyncWriteExt;
use tokio::net::TcpListener;
use tokio::sync::broadcast;
use tracing::{info, warn};
pub async fn run_tcp_server(
addr: &str,