refactor adapters into modular file structure
config-sqlite: split into repository/ (per entity) + serialization/ (per type) + error.rs http-api: split into dto/ (per resource) + routes/ (per resource) tcp-server: split into broadcaster, event_bus, server, error rss: split parser from adapter, external tests media: split error, external tests
This commit is contained in:
14
crates/adapters/config-sqlite/src/error.rs
Normal file
14
crates/adapters/config-sqlite/src/error.rs
Normal file
@@ -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}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Option<WidgetConfig>, Self::Error> {
|
||||
let row = sqlx::query("SELECT * FROM widgets WHERE id = ?")
|
||||
.bind(id as i64)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(SqliteConfigError::Sql)?;
|
||||
|
||||
match row {
|
||||
None => Ok(None),
|
||||
Some(row) => Ok(Some(ser::widget_from_row(&row)?)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_widgets(&self) -> Result<Vec<WidgetConfig>, Self::Error> {
|
||||
let rows = sqlx::query("SELECT * FROM widgets")
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(SqliteConfigError::Sql)?;
|
||||
|
||||
rows.iter().map(|r| ser::widget_from_row(r)).collect()
|
||||
}
|
||||
|
||||
async fn save_widget(&self, config: &WidgetConfig) -> Result<(), Self::Error> {
|
||||
let mappings_json = ser::mappings_to_json(&config.mappings)?;
|
||||
let hint_str = ser::display_hint_to_str(&config.display_hint);
|
||||
|
||||
sqlx::query(
|
||||
"INSERT OR REPLACE INTO widgets (id, name, display_hint, data_source_id, mappings, max_data_size)
|
||||
VALUES (?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
.bind(config.id as i64)
|
||||
.bind(&config.name)
|
||||
.bind(hint_str)
|
||||
.bind(config.data_source_id as i64)
|
||||
.bind(&mappings_json)
|
||||
.bind(config.max_data_size as i64)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(SqliteConfigError::Sql)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_widget(&self, id: WidgetId) -> Result<(), Self::Error> {
|
||||
sqlx::query("DELETE FROM widgets WHERE id = ?")
|
||||
.bind(id as i64)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(SqliteConfigError::Sql)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_data_source(&self, id: DataSourceId) -> Result<Option<DataSource>, Self::Error> {
|
||||
let row = sqlx::query("SELECT * FROM data_sources WHERE id = ?")
|
||||
.bind(id as i64)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(SqliteConfigError::Sql)?;
|
||||
|
||||
match row {
|
||||
None => Ok(None),
|
||||
Some(row) => Ok(Some(ser::data_source_from_row(&row)?)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_data_sources(&self) -> Result<Vec<DataSource>, Self::Error> {
|
||||
let rows = sqlx::query("SELECT * FROM data_sources")
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(SqliteConfigError::Sql)?;
|
||||
|
||||
rows.iter().map(|r| ser::data_source_from_row(r)).collect()
|
||||
}
|
||||
|
||||
async fn save_data_source(&self, source: &DataSource) -> Result<(), Self::Error> {
|
||||
let config_json = ser::data_source_config_to_json(&source.config)?;
|
||||
let type_str = ser::data_source_type_to_str(&source.source_type);
|
||||
|
||||
sqlx::query(
|
||||
"INSERT OR REPLACE INTO data_sources (id, name, source_type, poll_interval_secs, config)
|
||||
VALUES (?, ?, ?, ?, ?)"
|
||||
)
|
||||
.bind(source.id as i64)
|
||||
.bind(&source.name)
|
||||
.bind(type_str)
|
||||
.bind(source.poll_interval.as_secs() as i64)
|
||||
.bind(&config_json)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(SqliteConfigError::Sql)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_data_source(&self, id: DataSourceId) -> Result<(), Self::Error> {
|
||||
sqlx::query("DELETE FROM data_sources WHERE id = ?")
|
||||
.bind(id as i64)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(SqliteConfigError::Sql)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_layout(&self) -> Result<Option<Layout>, Self::Error> {
|
||||
let row = sqlx::query("SELECT data FROM layout WHERE id = 1")
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(SqliteConfigError::Sql)?;
|
||||
|
||||
match row {
|
||||
None => Ok(None),
|
||||
Some(row) => {
|
||||
let json: String = row.get("data");
|
||||
Ok(Some(ser::layout_from_json(&json)?))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn save_layout(&self, layout: &Layout) -> Result<(), Self::Error> {
|
||||
let json = ser::layout_to_json(layout)?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT OR REPLACE INTO layout (id, data) VALUES (1, ?)"
|
||||
)
|
||||
.bind(&json)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(SqliteConfigError::Sql)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_preset(&self, id: LayoutPresetId) -> Result<Option<LayoutPreset>, Self::Error> {
|
||||
let row = sqlx::query("SELECT * FROM presets WHERE id = ?")
|
||||
.bind(id as i64)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(SqliteConfigError::Sql)?;
|
||||
|
||||
match row {
|
||||
None => Ok(None),
|
||||
Some(row) => Ok(Some(ser::preset_from_row(&row)?)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_presets(&self) -> Result<Vec<LayoutPreset>, Self::Error> {
|
||||
let rows = sqlx::query("SELECT * FROM presets")
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(SqliteConfigError::Sql)?;
|
||||
|
||||
rows.iter().map(|r| ser::preset_from_row(r)).collect()
|
||||
}
|
||||
|
||||
async fn save_preset(&self, preset: &LayoutPreset) -> Result<(), Self::Error> {
|
||||
let layout_json = ser::layout_to_json(&preset.layout)?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT OR REPLACE INTO presets (id, name, layout_data) VALUES (?, ?, ?)"
|
||||
)
|
||||
.bind(preset.id as i64)
|
||||
.bind(&preset.name)
|
||||
.bind(&layout_json)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(SqliteConfigError::Sql)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_preset(&self, id: LayoutPresetId) -> Result<(), Self::Error> {
|
||||
sqlx::query("DELETE FROM presets WHERE id = ?")
|
||||
.bind(id as i64)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(SqliteConfigError::Sql)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
57
crates/adapters/config-sqlite/src/repository/data_sources.rs
Normal file
57
crates/adapters/config-sqlite/src/repository/data_sources.rs
Normal file
@@ -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<Option<DataSource>, 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<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()
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
}
|
||||
36
crates/adapters/config-sqlite/src/repository/layout.rs
Normal file
36
crates/adapters/config-sqlite/src/repository/layout.rs
Normal file
@@ -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<Option<Layout>, 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(())
|
||||
}
|
||||
}
|
||||
73
crates/adapters/config-sqlite/src/repository/mod.rs
Normal file
73
crates/adapters/config-sqlite/src/repository/mod.rs
Normal file
@@ -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<Option<WidgetConfig>, Self::Error> {
|
||||
self.get_widget_impl(id).await
|
||||
}
|
||||
|
||||
async fn list_widgets(&self) -> Result<Vec<WidgetConfig>, 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<Option<DataSource>, Self::Error> {
|
||||
self.get_data_source_impl(id).await
|
||||
}
|
||||
|
||||
async fn list_data_sources(&self) -> Result<Vec<DataSource>, 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<Option<Layout>, 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<Option<LayoutPreset>, Self::Error> {
|
||||
self.get_preset_impl(id).await
|
||||
}
|
||||
|
||||
async fn list_presets(&self) -> Result<Vec<LayoutPreset>, 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
|
||||
}
|
||||
}
|
||||
53
crates/adapters/config-sqlite/src/repository/presets.rs
Normal file
53
crates/adapters/config-sqlite/src/repository/presets.rs
Normal file
@@ -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<Option<LayoutPreset>, 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<Vec<LayoutPreset>, 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(())
|
||||
}
|
||||
}
|
||||
58
crates/adapters/config-sqlite/src/repository/widgets.rs
Normal file
58
crates/adapters/config-sqlite/src/repository/widgets.rs
Normal file
@@ -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<Option<WidgetConfig>, 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<Vec<WidgetConfig>, 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(())
|
||||
}
|
||||
}
|
||||
@@ -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<DisplayHint, SqliteConfigError> {
|
||||
match s {
|
||||
"icon_value" => Ok(DisplayHint::IconValue),
|
||||
"text_block" => Ok(DisplayHint::TextBlock),
|
||||
"key_value" => Ok(DisplayHint::KeyValue),
|
||||
_ => Err(SqliteConfigError::Serialization(format!("unknown display hint: {s}"))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn data_source_type_to_str(t: &DataSourceType) -> &'static str {
|
||||
match t {
|
||||
DataSourceType::Weather => "weather",
|
||||
DataSourceType::Media => "media",
|
||||
DataSourceType::Xtb => "xtb",
|
||||
DataSourceType::Rss => "rss",
|
||||
DataSourceType::HttpJson => "http_json",
|
||||
DataSourceType::Webhook => "webhook",
|
||||
}
|
||||
}
|
||||
|
||||
fn data_source_type_from_str(s: &str) -> Result<DataSourceType, SqliteConfigError> {
|
||||
match s {
|
||||
"weather" => Ok(DataSourceType::Weather),
|
||||
"media" => Ok(DataSourceType::Media),
|
||||
"xtb" => Ok(DataSourceType::Xtb),
|
||||
"rss" => Ok(DataSourceType::Rss),
|
||||
"http_json" => Ok(DataSourceType::HttpJson),
|
||||
"webhook" => Ok(DataSourceType::Webhook),
|
||||
_ => Err(SqliteConfigError::Serialization(format!("unknown source type: {s}"))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mappings_to_json(mappings: &[KeyMapping]) -> Result<String, SqliteConfigError> {
|
||||
let entries: Vec<serde_json::Value> = mappings.iter().map(|m| {
|
||||
serde_json::json!({
|
||||
"source_path": m.source_path,
|
||||
"target_key": m.target_key,
|
||||
})
|
||||
}).collect();
|
||||
serde_json::to_string(&entries).map_err(|e| SqliteConfigError::Serialization(e.to_string()))
|
||||
}
|
||||
|
||||
fn mappings_from_json(json: &str) -> Result<Vec<KeyMapping>, SqliteConfigError> {
|
||||
let entries: Vec<serde_json::Value> = serde_json::from_str(json)
|
||||
.map_err(|e| SqliteConfigError::Serialization(e.to_string()))?;
|
||||
|
||||
entries.iter().map(|v| {
|
||||
Ok(KeyMapping {
|
||||
source_path: v["source_path"].as_str()
|
||||
.ok_or_else(|| SqliteConfigError::Serialization("missing source_path".into()))?.into(),
|
||||
target_key: v["target_key"].as_str()
|
||||
.ok_or_else(|| SqliteConfigError::Serialization("missing target_key".into()))?.into(),
|
||||
})
|
||||
}).collect()
|
||||
}
|
||||
|
||||
pub fn data_source_config_to_json(config: &DataSourceConfig) -> Result<String, SqliteConfigError> {
|
||||
let v = serde_json::json!({
|
||||
"url": config.url,
|
||||
"headers": config.headers,
|
||||
"api_key": config.api_key,
|
||||
});
|
||||
serde_json::to_string(&v).map_err(|e| SqliteConfigError::Serialization(e.to_string()))
|
||||
}
|
||||
|
||||
fn data_source_config_from_json(json: &str) -> Result<DataSourceConfig, SqliteConfigError> {
|
||||
let v: serde_json::Value = serde_json::from_str(json)
|
||||
.map_err(|e| SqliteConfigError::Serialization(e.to_string()))?;
|
||||
|
||||
let url = v["url"].as_str().map(String::from);
|
||||
let api_key = v["api_key"].as_str().map(String::from);
|
||||
let headers = match v["headers"].as_array() {
|
||||
Some(arr) => arr.iter().filter_map(|h| {
|
||||
let pair = h.as_array()?;
|
||||
Some((pair[0].as_str()?.into(), pair[1].as_str()?.into()))
|
||||
}).collect(),
|
||||
None => vec![],
|
||||
};
|
||||
|
||||
Ok(DataSourceConfig { url, headers, api_key })
|
||||
}
|
||||
|
||||
pub fn layout_to_json(layout: &Layout) -> Result<String, SqliteConfigError> {
|
||||
let v = node_to_json(&layout.root);
|
||||
serde_json::to_string(&v).map_err(|e| SqliteConfigError::Serialization(e.to_string()))
|
||||
}
|
||||
|
||||
pub fn layout_from_json(json: &str) -> Result<Layout, SqliteConfigError> {
|
||||
let v: serde_json::Value = serde_json::from_str(json)
|
||||
.map_err(|e| SqliteConfigError::Serialization(e.to_string()))?;
|
||||
let root = node_from_json(&v)?;
|
||||
Ok(Layout { root })
|
||||
}
|
||||
|
||||
fn node_to_json(node: &LayoutNode) -> serde_json::Value {
|
||||
match node {
|
||||
LayoutNode::Leaf(id) => serde_json::json!({ "type": "leaf", "widget_id": id }),
|
||||
LayoutNode::Container(c) => {
|
||||
let children: Vec<serde_json::Value> = c.children.iter().map(|ch| {
|
||||
let sizing = match &ch.sizing {
|
||||
Sizing::Fixed(px) => serde_json::json!({ "type": "fixed", "value": px }),
|
||||
Sizing::Flex(w) => serde_json::json!({ "type": "flex", "value": w }),
|
||||
};
|
||||
serde_json::json!({
|
||||
"sizing": sizing,
|
||||
"node": node_to_json(&ch.node),
|
||||
})
|
||||
}).collect();
|
||||
|
||||
serde_json::json!({
|
||||
"type": "container",
|
||||
"direction": match c.direction { Direction::Row => "row", Direction::Column => "column" },
|
||||
"gap": c.gap,
|
||||
"padding": c.padding,
|
||||
"children": children,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn node_from_json(v: &serde_json::Value) -> Result<LayoutNode, SqliteConfigError> {
|
||||
let err = |msg: &str| SqliteConfigError::Serialization(msg.into());
|
||||
|
||||
match v["type"].as_str().ok_or_else(|| err("missing node type"))? {
|
||||
"leaf" => {
|
||||
let id = v["widget_id"].as_u64().ok_or_else(|| err("missing widget_id"))? as u16;
|
||||
Ok(LayoutNode::Leaf(id))
|
||||
}
|
||||
"container" => {
|
||||
let direction = match v["direction"].as_str().ok_or_else(|| err("missing direction"))? {
|
||||
"row" => Direction::Row,
|
||||
"column" => Direction::Column,
|
||||
d => return Err(err(&format!("unknown direction: {d}"))),
|
||||
};
|
||||
let gap = v["gap"].as_u64().unwrap_or(0) as u8;
|
||||
let padding = v["padding"].as_u64().unwrap_or(0) as u8;
|
||||
let children = v["children"].as_array()
|
||||
.ok_or_else(|| err("missing children"))?
|
||||
.iter()
|
||||
.map(|ch| {
|
||||
let sizing_v = &ch["sizing"];
|
||||
let sizing = match sizing_v["type"].as_str().ok_or_else(|| err("missing sizing type"))? {
|
||||
"fixed" => Sizing::Fixed(sizing_v["value"].as_u64().ok_or_else(|| err("missing fixed value"))? as u16),
|
||||
"flex" => Sizing::Flex(sizing_v["value"].as_u64().ok_or_else(|| err("missing flex value"))? as u8),
|
||||
s => return Err(err(&format!("unknown sizing: {s}"))),
|
||||
};
|
||||
let node = node_from_json(&ch["node"])?;
|
||||
Ok(LayoutChild { sizing, node })
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(LayoutNode::Container(ContainerNode { direction, gap, padding, children }))
|
||||
}
|
||||
t => Err(err(&format!("unknown node type: {t}"))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn widget_from_row(row: &SqliteRow) -> Result<WidgetConfig, SqliteConfigError> {
|
||||
let id: i64 = row.get("id");
|
||||
let name: String = row.get("name");
|
||||
let hint_str: String = row.get("display_hint");
|
||||
let ds_id: i64 = row.get("data_source_id");
|
||||
let mappings_json: String = row.get("mappings");
|
||||
let max_size: i64 = row.get("max_data_size");
|
||||
|
||||
Ok(WidgetConfig {
|
||||
id: id as u16,
|
||||
name,
|
||||
display_hint: display_hint_from_str(&hint_str)?,
|
||||
data_source_id: ds_id as u16,
|
||||
mappings: mappings_from_json(&mappings_json)?,
|
||||
max_data_size: max_size as u16,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn data_source_from_row(row: &SqliteRow) -> Result<DataSource, SqliteConfigError> {
|
||||
let id: i64 = row.get("id");
|
||||
let name: String = row.get("name");
|
||||
let type_str: String = row.get("source_type");
|
||||
let interval_secs: i64 = row.get("poll_interval_secs");
|
||||
let config_json: String = row.get("config");
|
||||
|
||||
Ok(DataSource {
|
||||
id: id as u16,
|
||||
name,
|
||||
source_type: data_source_type_from_str(&type_str)?,
|
||||
poll_interval: Duration::from_secs(interval_secs as u64),
|
||||
config: data_source_config_from_json(&config_json)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn preset_from_row(row: &SqliteRow) -> Result<LayoutPreset, SqliteConfigError> {
|
||||
let id: i64 = row.get("id");
|
||||
let name: String = row.get("name");
|
||||
let layout_json: String = row.get("layout_data");
|
||||
|
||||
Ok(LayoutPreset {
|
||||
id: id as u16,
|
||||
name,
|
||||
layout: layout_from_json(&layout_json)?,
|
||||
})
|
||||
}
|
||||
@@ -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<DataSourceType, SqliteConfigError> {
|
||||
match s {
|
||||
"weather" => Ok(DataSourceType::Weather),
|
||||
"media" => Ok(DataSourceType::Media),
|
||||
"xtb" => Ok(DataSourceType::Xtb),
|
||||
"rss" => Ok(DataSourceType::Rss),
|
||||
"http_json" => Ok(DataSourceType::HttpJson),
|
||||
"webhook" => Ok(DataSourceType::Webhook),
|
||||
_ => Err(SqliteConfigError::Serialization(format!("unknown source type: {s}"))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn data_source_config_to_json(config: &DataSourceConfig) -> Result<String, SqliteConfigError> {
|
||||
let v = serde_json::json!({
|
||||
"url": config.url,
|
||||
"headers": config.headers,
|
||||
"api_key": config.api_key,
|
||||
});
|
||||
serde_json::to_string(&v).map_err(|e| SqliteConfigError::Serialization(e.to_string()))
|
||||
}
|
||||
|
||||
fn data_source_config_from_json(json: &str) -> Result<DataSourceConfig, SqliteConfigError> {
|
||||
let v: serde_json::Value = serde_json::from_str(json)
|
||||
.map_err(|e| SqliteConfigError::Serialization(e.to_string()))?;
|
||||
|
||||
let url = v["url"].as_str().map(String::from);
|
||||
let api_key = v["api_key"].as_str().map(String::from);
|
||||
let headers = match v["headers"].as_array() {
|
||||
Some(arr) => arr.iter().filter_map(|h| {
|
||||
let pair = h.as_array()?;
|
||||
Some((pair[0].as_str()?.into(), pair[1].as_str()?.into()))
|
||||
}).collect(),
|
||||
None => vec![],
|
||||
};
|
||||
|
||||
Ok(DataSourceConfig { url, headers, api_key })
|
||||
}
|
||||
|
||||
pub fn data_source_from_row(row: &SqliteRow) -> Result<DataSource, SqliteConfigError> {
|
||||
let id: i64 = row.get("id");
|
||||
let name: String = row.get("name");
|
||||
let type_str: String = row.get("source_type");
|
||||
let interval_secs: i64 = row.get("poll_interval_secs");
|
||||
let config_json: String = row.get("config");
|
||||
|
||||
Ok(DataSource {
|
||||
id: id as u16,
|
||||
name,
|
||||
source_type: data_source_type_from_str(&type_str)?,
|
||||
poll_interval: Duration::from_secs(interval_secs as u64),
|
||||
config: data_source_config_from_json(&config_json)?,
|
||||
})
|
||||
}
|
||||
77
crates/adapters/config-sqlite/src/serialization/layout.rs
Normal file
77
crates/adapters/config-sqlite/src/serialization/layout.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
use domain::{ContainerNode, Direction, Layout, LayoutChild, LayoutNode, Sizing};
|
||||
use crate::error::SqliteConfigError;
|
||||
|
||||
pub fn layout_to_json(layout: &Layout) -> Result<String, SqliteConfigError> {
|
||||
let v = node_to_json(&layout.root);
|
||||
serde_json::to_string(&v).map_err(|e| SqliteConfigError::Serialization(e.to_string()))
|
||||
}
|
||||
|
||||
pub fn layout_from_json(json: &str) -> Result<Layout, SqliteConfigError> {
|
||||
let v: serde_json::Value = serde_json::from_str(json)
|
||||
.map_err(|e| SqliteConfigError::Serialization(e.to_string()))?;
|
||||
let root = node_from_json(&v)?;
|
||||
Ok(Layout { root })
|
||||
}
|
||||
|
||||
fn node_to_json(node: &LayoutNode) -> serde_json::Value {
|
||||
match node {
|
||||
LayoutNode::Leaf(id) => serde_json::json!({ "type": "leaf", "widget_id": id }),
|
||||
LayoutNode::Container(c) => {
|
||||
let children: Vec<serde_json::Value> = c.children.iter().map(|ch| {
|
||||
let sizing = match &ch.sizing {
|
||||
Sizing::Fixed(px) => serde_json::json!({ "type": "fixed", "value": px }),
|
||||
Sizing::Flex(w) => serde_json::json!({ "type": "flex", "value": w }),
|
||||
};
|
||||
serde_json::json!({
|
||||
"sizing": sizing,
|
||||
"node": node_to_json(&ch.node),
|
||||
})
|
||||
}).collect();
|
||||
|
||||
serde_json::json!({
|
||||
"type": "container",
|
||||
"direction": match c.direction { Direction::Row => "row", Direction::Column => "column" },
|
||||
"gap": c.gap,
|
||||
"padding": c.padding,
|
||||
"children": children,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn node_from_json(v: &serde_json::Value) -> Result<LayoutNode, SqliteConfigError> {
|
||||
let err = |msg: &str| SqliteConfigError::Serialization(msg.into());
|
||||
|
||||
match v["type"].as_str().ok_or_else(|| err("missing node type"))? {
|
||||
"leaf" => {
|
||||
let id = v["widget_id"].as_u64().ok_or_else(|| err("missing widget_id"))? as u16;
|
||||
Ok(LayoutNode::Leaf(id))
|
||||
}
|
||||
"container" => {
|
||||
let direction = match v["direction"].as_str().ok_or_else(|| err("missing direction"))? {
|
||||
"row" => Direction::Row,
|
||||
"column" => Direction::Column,
|
||||
d => return Err(err(&format!("unknown direction: {d}"))),
|
||||
};
|
||||
let gap = v["gap"].as_u64().unwrap_or(0) as u8;
|
||||
let padding = v["padding"].as_u64().unwrap_or(0) as u8;
|
||||
let children = v["children"].as_array()
|
||||
.ok_or_else(|| err("missing children"))?
|
||||
.iter()
|
||||
.map(|ch| {
|
||||
let sizing_v = &ch["sizing"];
|
||||
let sizing = match sizing_v["type"].as_str().ok_or_else(|| err("missing sizing type"))? {
|
||||
"fixed" => Sizing::Fixed(sizing_v["value"].as_u64().ok_or_else(|| err("missing fixed value"))? as u16),
|
||||
"flex" => Sizing::Flex(sizing_v["value"].as_u64().ok_or_else(|| err("missing flex value"))? as u8),
|
||||
s => return Err(err(&format!("unknown sizing: {s}"))),
|
||||
};
|
||||
let node = node_from_json(&ch["node"])?;
|
||||
Ok(LayoutChild { sizing, node })
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(LayoutNode::Container(ContainerNode { direction, gap, padding, children }))
|
||||
}
|
||||
t => Err(err(&format!("unknown node type: {t}"))),
|
||||
}
|
||||
}
|
||||
4
crates/adapters/config-sqlite/src/serialization/mod.rs
Normal file
4
crates/adapters/config-sqlite/src/serialization/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod widget;
|
||||
pub mod data_source;
|
||||
pub mod layout;
|
||||
pub mod preset;
|
||||
17
crates/adapters/config-sqlite/src/serialization/preset.rs
Normal file
17
crates/adapters/config-sqlite/src/serialization/preset.rs
Normal file
@@ -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<LayoutPreset, SqliteConfigError> {
|
||||
let id: i64 = row.get("id");
|
||||
let name: String = row.get("name");
|
||||
let layout_json: String = row.get("layout_data");
|
||||
|
||||
Ok(LayoutPreset {
|
||||
id: id as u16,
|
||||
name,
|
||||
layout: layout_from_json(&layout_json)?,
|
||||
})
|
||||
}
|
||||
63
crates/adapters/config-sqlite/src/serialization/widget.rs
Normal file
63
crates/adapters/config-sqlite/src/serialization/widget.rs
Normal file
@@ -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<DisplayHint, SqliteConfigError> {
|
||||
match s {
|
||||
"icon_value" => Ok(DisplayHint::IconValue),
|
||||
"text_block" => Ok(DisplayHint::TextBlock),
|
||||
"key_value" => Ok(DisplayHint::KeyValue),
|
||||
_ => Err(SqliteConfigError::Serialization(format!("unknown display hint: {s}"))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mappings_to_json(mappings: &[KeyMapping]) -> Result<String, SqliteConfigError> {
|
||||
let entries: Vec<serde_json::Value> = mappings.iter().map(|m| {
|
||||
serde_json::json!({
|
||||
"source_path": m.source_path,
|
||||
"target_key": m.target_key,
|
||||
})
|
||||
}).collect();
|
||||
serde_json::to_string(&entries).map_err(|e| SqliteConfigError::Serialization(e.to_string()))
|
||||
}
|
||||
|
||||
fn mappings_from_json(json: &str) -> Result<Vec<KeyMapping>, SqliteConfigError> {
|
||||
let entries: Vec<serde_json::Value> = serde_json::from_str(json)
|
||||
.map_err(|e| SqliteConfigError::Serialization(e.to_string()))?;
|
||||
|
||||
entries.iter().map(|v| {
|
||||
Ok(KeyMapping {
|
||||
source_path: v["source_path"].as_str()
|
||||
.ok_or_else(|| SqliteConfigError::Serialization("missing source_path".into()))?.into(),
|
||||
target_key: v["target_key"].as_str()
|
||||
.ok_or_else(|| SqliteConfigError::Serialization("missing target_key".into()))?.into(),
|
||||
})
|
||||
}).collect()
|
||||
}
|
||||
|
||||
pub fn widget_from_row(row: &SqliteRow) -> Result<WidgetConfig, SqliteConfigError> {
|
||||
let id: i64 = row.get("id");
|
||||
let name: String = row.get("name");
|
||||
let hint_str: String = row.get("display_hint");
|
||||
let ds_id: i64 = row.get("data_source_id");
|
||||
let mappings_json: String = row.get("mappings");
|
||||
let max_size: i64 = row.get("max_data_size");
|
||||
|
||||
Ok(WidgetConfig {
|
||||
id: id as u16,
|
||||
name,
|
||||
display_hint: display_hint_from_str(&hint_str)?,
|
||||
data_source_id: ds_id as u16,
|
||||
mappings: mappings_from_json(&mappings_json)?,
|
||||
max_data_size: max_size as u16,
|
||||
})
|
||||
}
|
||||
@@ -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<KeyMappingDto>,
|
||||
pub max_data_size: u16,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct CreateWidgetDto {
|
||||
pub id: u16,
|
||||
pub name: String,
|
||||
pub display_hint: String,
|
||||
pub data_source_id: u16,
|
||||
pub mappings: Vec<KeyMappingDto>,
|
||||
#[serde(default = "default_max_data_size")]
|
||||
pub max_data_size: u16,
|
||||
}
|
||||
|
||||
fn default_max_data_size() -> u16 { 2048 }
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct DataSourceDto {
|
||||
pub id: u16,
|
||||
pub name: String,
|
||||
pub source_type: String,
|
||||
pub poll_interval_secs: u64,
|
||||
pub url: Option<String>,
|
||||
pub api_key: Option<String>,
|
||||
pub headers: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct SizingDto {
|
||||
#[serde(rename = "type")]
|
||||
pub sizing_type: String,
|
||||
pub value: u16,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct LayoutNodeDto {
|
||||
#[serde(rename = "type")]
|
||||
pub node_type: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub widget_id: Option<u16>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub direction: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub gap: Option<u8>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub padding: Option<u8>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub children: Option<Vec<LayoutChildDto>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct LayoutChildDto {
|
||||
pub sizing: SizingDto,
|
||||
pub node: LayoutNodeDto,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct LayoutDto {
|
||||
pub root: LayoutNodeDto,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct PresetDto {
|
||||
pub id: u16,
|
||||
pub name: String,
|
||||
pub layout: LayoutDto,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct CreatePresetDto {
|
||||
pub id: u16,
|
||||
pub name: String,
|
||||
pub layout: LayoutDto,
|
||||
}
|
||||
|
||||
use domain::*;
|
||||
use std::time::Duration;
|
||||
|
||||
impl From<&WidgetConfig> for WidgetDto {
|
||||
fn from(w: &WidgetConfig) -> Self {
|
||||
Self {
|
||||
id: w.id,
|
||||
name: w.name.clone(),
|
||||
display_hint: match w.display_hint {
|
||||
DisplayHint::IconValue => "icon_value",
|
||||
DisplayHint::TextBlock => "text_block",
|
||||
DisplayHint::KeyValue => "key_value",
|
||||
}.into(),
|
||||
data_source_id: w.data_source_id,
|
||||
mappings: w.mappings.iter().map(|m| KeyMappingDto {
|
||||
source_path: m.source_path.clone(),
|
||||
target_key: m.target_key.clone(),
|
||||
}).collect(),
|
||||
max_data_size: w.max_data_size,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CreateWidgetDto {
|
||||
pub fn into_domain(self) -> Result<WidgetConfig, String> {
|
||||
let hint = match self.display_hint.as_str() {
|
||||
"icon_value" => DisplayHint::IconValue,
|
||||
"text_block" => DisplayHint::TextBlock,
|
||||
"key_value" => DisplayHint::KeyValue,
|
||||
h => return Err(format!("unknown display_hint: {h}")),
|
||||
};
|
||||
Ok(WidgetConfig {
|
||||
id: self.id,
|
||||
name: self.name,
|
||||
display_hint: hint,
|
||||
data_source_id: self.data_source_id,
|
||||
mappings: self.mappings.into_iter().map(|m| KeyMapping {
|
||||
source_path: m.source_path,
|
||||
target_key: m.target_key,
|
||||
}).collect(),
|
||||
max_data_size: self.max_data_size,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&DataSource> for DataSourceDto {
|
||||
fn from(ds: &DataSource) -> Self {
|
||||
Self {
|
||||
id: ds.id,
|
||||
name: ds.name.clone(),
|
||||
source_type: match ds.source_type {
|
||||
DataSourceType::Weather => "weather",
|
||||
DataSourceType::Media => "media",
|
||||
DataSourceType::Xtb => "xtb",
|
||||
DataSourceType::Rss => "rss",
|
||||
DataSourceType::HttpJson => "http_json",
|
||||
DataSourceType::Webhook => "webhook",
|
||||
}.into(),
|
||||
poll_interval_secs: ds.poll_interval.as_secs(),
|
||||
url: ds.config.url.clone(),
|
||||
api_key: ds.config.api_key.clone(),
|
||||
headers: ds.config.headers.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DataSourceDto {
|
||||
pub fn into_domain(self) -> Result<DataSource, String> {
|
||||
let source_type = match self.source_type.as_str() {
|
||||
"weather" => DataSourceType::Weather,
|
||||
"media" => DataSourceType::Media,
|
||||
"xtb" => DataSourceType::Xtb,
|
||||
"rss" => DataSourceType::Rss,
|
||||
"http_json" => DataSourceType::HttpJson,
|
||||
"webhook" => DataSourceType::Webhook,
|
||||
t => return Err(format!("unknown source_type: {t}")),
|
||||
};
|
||||
Ok(DataSource {
|
||||
id: self.id,
|
||||
name: self.name,
|
||||
source_type,
|
||||
poll_interval: Duration::from_secs(self.poll_interval_secs),
|
||||
config: DataSourceConfig {
|
||||
url: self.url,
|
||||
api_key: self.api_key,
|
||||
headers: self.headers,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&LayoutNode> for LayoutNodeDto {
|
||||
fn from(node: &LayoutNode) -> Self {
|
||||
match node {
|
||||
LayoutNode::Leaf(id) => Self {
|
||||
node_type: "leaf".into(),
|
||||
widget_id: Some(*id),
|
||||
direction: None, gap: None, padding: None, children: None,
|
||||
},
|
||||
LayoutNode::Container(c) => Self {
|
||||
node_type: "container".into(),
|
||||
widget_id: None,
|
||||
direction: Some(match c.direction {
|
||||
Direction::Row => "row",
|
||||
Direction::Column => "column",
|
||||
}.into()),
|
||||
gap: Some(c.gap),
|
||||
padding: Some(c.padding),
|
||||
children: Some(c.children.iter().map(|ch| LayoutChildDto {
|
||||
sizing: SizingDto {
|
||||
sizing_type: match ch.sizing {
|
||||
Sizing::Fixed(_) => "fixed".into(),
|
||||
Sizing::Flex(_) => "flex".into(),
|
||||
},
|
||||
value: match ch.sizing {
|
||||
Sizing::Fixed(v) => v,
|
||||
Sizing::Flex(v) => v as u16,
|
||||
},
|
||||
},
|
||||
node: (&ch.node).into(),
|
||||
}).collect()),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LayoutNodeDto {
|
||||
pub fn into_domain(self) -> Result<LayoutNode, String> {
|
||||
match self.node_type.as_str() {
|
||||
"leaf" => {
|
||||
let id = self.widget_id.ok_or("missing widget_id")?;
|
||||
Ok(LayoutNode::Leaf(id))
|
||||
}
|
||||
"container" => {
|
||||
let direction = match self.direction.as_deref().ok_or("missing direction")? {
|
||||
"row" => Direction::Row,
|
||||
"column" => Direction::Column,
|
||||
d => return Err(format!("unknown direction: {d}")),
|
||||
};
|
||||
let children = self.children.ok_or("missing children")?
|
||||
.into_iter()
|
||||
.map(|ch| {
|
||||
let sizing = match ch.sizing.sizing_type.as_str() {
|
||||
"fixed" => Sizing::Fixed(ch.sizing.value),
|
||||
"flex" => Sizing::Flex(ch.sizing.value as u8),
|
||||
s => return Err(format!("unknown sizing: {s}")),
|
||||
};
|
||||
let node = ch.node.into_domain()?;
|
||||
Ok(LayoutChild { sizing, node })
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(LayoutNode::Container(ContainerNode {
|
||||
direction,
|
||||
gap: self.gap.unwrap_or(0),
|
||||
padding: self.padding.unwrap_or(0),
|
||||
children,
|
||||
}))
|
||||
}
|
||||
t => Err(format!("unknown node type: {t}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&Layout> for LayoutDto {
|
||||
fn from(l: &Layout) -> Self {
|
||||
Self { root: (&l.root).into() }
|
||||
}
|
||||
}
|
||||
|
||||
impl LayoutDto {
|
||||
pub fn into_domain(self) -> Result<Layout, String> {
|
||||
Ok(Layout { root: self.root.into_domain()? })
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&LayoutPreset> for PresetDto {
|
||||
fn from(p: &LayoutPreset) -> Self {
|
||||
Self {
|
||||
id: p.id,
|
||||
name: p.name.clone(),
|
||||
layout: (&p.layout).into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CreatePresetDto {
|
||||
pub fn into_domain(self) -> Result<LayoutPreset, String> {
|
||||
Ok(LayoutPreset {
|
||||
id: self.id,
|
||||
name: self.name,
|
||||
layout: self.layout.into_domain()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
60
crates/adapters/http-api/src/dto/data_source.rs
Normal file
60
crates/adapters/http-api/src/dto/data_source.rs
Normal file
@@ -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<String>,
|
||||
pub api_key: Option<String>,
|
||||
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<DataSource, String> {
|
||||
let source_type = match self.source_type.as_str() {
|
||||
"weather" => DataSourceType::Weather,
|
||||
"media" => DataSourceType::Media,
|
||||
"xtb" => DataSourceType::Xtb,
|
||||
"rss" => DataSourceType::Rss,
|
||||
"http_json" => DataSourceType::HttpJson,
|
||||
"webhook" => DataSourceType::Webhook,
|
||||
t => return Err(format!("unknown source_type: {t}")),
|
||||
};
|
||||
Ok(DataSource {
|
||||
id: self.id,
|
||||
name: self.name,
|
||||
source_type,
|
||||
poll_interval: Duration::from_secs(self.poll_interval_secs),
|
||||
config: DataSourceConfig {
|
||||
url: self.url,
|
||||
api_key: self.api_key,
|
||||
headers: self.headers,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
121
crates/adapters/http-api/src/dto/layout.rs
Normal file
121
crates/adapters/http-api/src/dto/layout.rs
Normal file
@@ -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<u16>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub direction: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub gap: Option<u8>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub padding: Option<u8>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub children: Option<Vec<LayoutChildDto>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct LayoutChildDto {
|
||||
pub sizing: SizingDto,
|
||||
pub node: LayoutNodeDto,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct LayoutDto {
|
||||
pub root: LayoutNodeDto,
|
||||
}
|
||||
|
||||
impl From<&LayoutNode> for LayoutNodeDto {
|
||||
fn from(node: &LayoutNode) -> Self {
|
||||
match node {
|
||||
LayoutNode::Leaf(id) => Self {
|
||||
node_type: "leaf".into(),
|
||||
widget_id: Some(*id),
|
||||
direction: None, gap: None, padding: None, children: None,
|
||||
},
|
||||
LayoutNode::Container(c) => Self {
|
||||
node_type: "container".into(),
|
||||
widget_id: None,
|
||||
direction: Some(match c.direction {
|
||||
Direction::Row => "row",
|
||||
Direction::Column => "column",
|
||||
}.into()),
|
||||
gap: Some(c.gap),
|
||||
padding: Some(c.padding),
|
||||
children: Some(c.children.iter().map(|ch| LayoutChildDto {
|
||||
sizing: SizingDto {
|
||||
sizing_type: match ch.sizing {
|
||||
Sizing::Fixed(_) => "fixed".into(),
|
||||
Sizing::Flex(_) => "flex".into(),
|
||||
},
|
||||
value: match ch.sizing {
|
||||
Sizing::Fixed(v) => v,
|
||||
Sizing::Flex(v) => v as u16,
|
||||
},
|
||||
},
|
||||
node: (&ch.node).into(),
|
||||
}).collect()),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LayoutNodeDto {
|
||||
pub fn into_domain(self) -> Result<LayoutNode, String> {
|
||||
match self.node_type.as_str() {
|
||||
"leaf" => {
|
||||
let id = self.widget_id.ok_or("missing widget_id")?;
|
||||
Ok(LayoutNode::Leaf(id))
|
||||
}
|
||||
"container" => {
|
||||
let direction = match self.direction.as_deref().ok_or("missing direction")? {
|
||||
"row" => Direction::Row,
|
||||
"column" => Direction::Column,
|
||||
d => return Err(format!("unknown direction: {d}")),
|
||||
};
|
||||
let children = self.children.ok_or("missing children")?
|
||||
.into_iter()
|
||||
.map(|ch| {
|
||||
let sizing = match ch.sizing.sizing_type.as_str() {
|
||||
"fixed" => Sizing::Fixed(ch.sizing.value),
|
||||
"flex" => Sizing::Flex(ch.sizing.value as u8),
|
||||
s => return Err(format!("unknown sizing: {s}")),
|
||||
};
|
||||
let node = ch.node.into_domain()?;
|
||||
Ok(LayoutChild { sizing, node })
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(LayoutNode::Container(ContainerNode {
|
||||
direction,
|
||||
gap: self.gap.unwrap_or(0),
|
||||
padding: self.padding.unwrap_or(0),
|
||||
children,
|
||||
}))
|
||||
}
|
||||
t => Err(format!("unknown node type: {t}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&Layout> for LayoutDto {
|
||||
fn from(l: &Layout) -> Self {
|
||||
Self { root: (&l.root).into() }
|
||||
}
|
||||
}
|
||||
|
||||
impl LayoutDto {
|
||||
pub fn into_domain(self) -> Result<Layout, String> {
|
||||
Ok(Layout { root: self.root.into_domain()? })
|
||||
}
|
||||
}
|
||||
9
crates/adapters/http-api/src/dto/mod.rs
Normal file
9
crates/adapters/http-api/src/dto/mod.rs
Normal file
@@ -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};
|
||||
37
crates/adapters/http-api/src/dto/preset.rs
Normal file
37
crates/adapters/http-api/src/dto/preset.rs
Normal file
@@ -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<LayoutPreset, String> {
|
||||
Ok(LayoutPreset {
|
||||
id: self.id,
|
||||
name: self.name,
|
||||
layout: self.layout.into_domain()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
73
crates/adapters/http-api/src/dto/widget.rs
Normal file
73
crates/adapters/http-api/src/dto/widget.rs
Normal file
@@ -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<KeyMappingDto>,
|
||||
pub max_data_size: u16,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct CreateWidgetDto {
|
||||
pub id: u16,
|
||||
pub name: String,
|
||||
pub display_hint: String,
|
||||
pub data_source_id: u16,
|
||||
pub mappings: Vec<KeyMappingDto>,
|
||||
#[serde(default = "default_max_data_size")]
|
||||
pub max_data_size: u16,
|
||||
}
|
||||
|
||||
fn default_max_data_size() -> u16 { 2048 }
|
||||
|
||||
impl From<&WidgetConfig> for WidgetDto {
|
||||
fn from(w: &WidgetConfig) -> Self {
|
||||
Self {
|
||||
id: w.id,
|
||||
name: w.name.clone(),
|
||||
display_hint: match w.display_hint {
|
||||
DisplayHint::IconValue => "icon_value",
|
||||
DisplayHint::TextBlock => "text_block",
|
||||
DisplayHint::KeyValue => "key_value",
|
||||
}.into(),
|
||||
data_source_id: w.data_source_id,
|
||||
mappings: w.mappings.iter().map(|m| KeyMappingDto {
|
||||
source_path: m.source_path.clone(),
|
||||
target_key: m.target_key.clone(),
|
||||
}).collect(),
|
||||
max_data_size: w.max_data_size,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CreateWidgetDto {
|
||||
pub fn into_domain(self) -> Result<WidgetConfig, String> {
|
||||
let hint = match self.display_hint.as_str() {
|
||||
"icon_value" => DisplayHint::IconValue,
|
||||
"text_block" => DisplayHint::TextBlock,
|
||||
"key_value" => DisplayHint::KeyValue,
|
||||
h => return Err(format!("unknown display_hint: {h}")),
|
||||
};
|
||||
Ok(WidgetConfig {
|
||||
id: self.id,
|
||||
name: self.name,
|
||||
display_hint: hint,
|
||||
data_source_id: self.data_source_id,
|
||||
mappings: self.mappings.into_iter().map(|m| KeyMapping {
|
||||
source_path: m.source_path,
|
||||
target_key: m.target_key,
|
||||
}).collect(),
|
||||
max_data_size: self.max_data_size,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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<C, E> = State<AppState<C, E>>;
|
||||
|
||||
pub fn api_routes<C, E>() -> Router<AppState<C, E>>
|
||||
where
|
||||
C: ConfigRepository + Send + Sync + 'static,
|
||||
C::Error: std::fmt::Debug + Send,
|
||||
E: EventPublisher + Send + Sync + 'static,
|
||||
E::Error: std::fmt::Debug + Send,
|
||||
{
|
||||
Router::new()
|
||||
.route("/widgets", get(list_widgets::<C, E>).post(create_widget::<C, E>))
|
||||
.route("/widgets/{id}", get(get_widget::<C, E>).put(update_widget::<C, E>).delete(delete_widget::<C, E>))
|
||||
.route("/data-sources", get(list_data_sources::<C, E>).post(create_data_source::<C, E>))
|
||||
.route("/data-sources/{id}", get(get_data_source::<C, E>).put(update_data_source::<C, E>).delete(delete_data_source::<C, E>))
|
||||
.route("/layout", get(get_layout::<C, E>).put(update_layout::<C, E>))
|
||||
.route("/presets", get(list_presets::<C, E>).post(create_preset::<C, E>))
|
||||
.route("/presets/{id}", get(get_preset::<C, E>).delete(delete_preset::<C, E>))
|
||||
.route("/presets/{id}/load", post(load_preset::<C, E>))
|
||||
}
|
||||
|
||||
async fn list_widgets<C, E>(State(state): S<C, E>) -> Result<Json<Vec<WidgetDto>>, StatusCode>
|
||||
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug,
|
||||
{
|
||||
let widgets = state.config.list_widgets().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
Ok(Json(widgets.iter().map(WidgetDto::from).collect()))
|
||||
}
|
||||
|
||||
async fn get_widget<C, E>(State(state): S<C, E>, Path(id): Path<u16>) -> Result<Json<WidgetDto>, StatusCode>
|
||||
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug,
|
||||
{
|
||||
let widget = state.config.get_widget(id).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
match widget {
|
||||
Some(w) => Ok(Json(WidgetDto::from(&w))),
|
||||
None => Err(StatusCode::NOT_FOUND),
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_widget<C, E>(State(state): S<C, E>, Json(body): Json<CreateWidgetDto>) -> Result<StatusCode, (StatusCode, String)>
|
||||
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug,
|
||||
{
|
||||
let widget = body.into_domain().map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
||||
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
|
||||
svc.create_widget(widget).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
|
||||
Ok(StatusCode::CREATED)
|
||||
}
|
||||
|
||||
async fn update_widget<C, E>(State(state): S<C, E>, Path(_id): Path<u16>, Json(body): Json<CreateWidgetDto>) -> Result<StatusCode, (StatusCode, String)>
|
||||
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug,
|
||||
{
|
||||
let widget = body.into_domain().map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
||||
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
|
||||
svc.update_widget(widget).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
async fn delete_widget<C, E>(State(state): S<C, E>, Path(id): Path<u16>) -> Result<StatusCode, StatusCode>
|
||||
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug,
|
||||
{
|
||||
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
|
||||
svc.delete_widget(id).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn list_data_sources<C, E>(State(state): S<C, E>) -> Result<Json<Vec<DataSourceDto>>, StatusCode>
|
||||
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug,
|
||||
{
|
||||
let sources = state.config.list_data_sources().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
Ok(Json(sources.iter().map(DataSourceDto::from).collect()))
|
||||
}
|
||||
|
||||
async fn get_data_source<C, E>(State(state): S<C, E>, Path(id): Path<u16>) -> Result<Json<DataSourceDto>, StatusCode>
|
||||
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug,
|
||||
{
|
||||
let source = state.config.get_data_source(id).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
match source {
|
||||
Some(s) => Ok(Json(DataSourceDto::from(&s))),
|
||||
None => Err(StatusCode::NOT_FOUND),
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_data_source<C, E>(State(state): S<C, E>, Json(body): Json<DataSourceDto>) -> Result<StatusCode, (StatusCode, String)>
|
||||
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug,
|
||||
{
|
||||
let source = body.into_domain().map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
||||
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
|
||||
svc.create_data_source(source).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
|
||||
Ok(StatusCode::CREATED)
|
||||
}
|
||||
|
||||
async fn update_data_source<C, E>(State(state): S<C, E>, Path(_id): Path<u16>, Json(body): Json<DataSourceDto>) -> Result<StatusCode, (StatusCode, String)>
|
||||
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug,
|
||||
{
|
||||
let source = body.into_domain().map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
||||
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
|
||||
svc.update_data_source(source).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
async fn delete_data_source<C, E>(State(state): S<C, E>, Path(id): Path<u16>) -> Result<StatusCode, StatusCode>
|
||||
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug,
|
||||
{
|
||||
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
|
||||
svc.delete_data_source(id).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn get_layout<C, E>(State(state): S<C, E>) -> Result<Json<Option<LayoutDto>>, StatusCode>
|
||||
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug,
|
||||
{
|
||||
let layout = state.config.get_layout().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
Ok(Json(layout.as_ref().map(LayoutDto::from)))
|
||||
}
|
||||
|
||||
async fn update_layout<C, E>(State(state): S<C, E>, Json(body): Json<LayoutDto>) -> Result<StatusCode, (StatusCode, String)>
|
||||
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug,
|
||||
{
|
||||
let layout = body.into_domain().map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
||||
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
|
||||
svc.update_layout(layout).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
async fn list_presets<C, E>(State(state): S<C, E>) -> Result<Json<Vec<PresetDto>>, StatusCode>
|
||||
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug,
|
||||
{
|
||||
let presets = state.config.list_presets().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
Ok(Json(presets.iter().map(PresetDto::from).collect()))
|
||||
}
|
||||
|
||||
async fn get_preset<C, E>(State(state): S<C, E>, Path(id): Path<u16>) -> Result<Json<PresetDto>, StatusCode>
|
||||
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug,
|
||||
{
|
||||
let preset = state.config.get_preset(id).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
match preset {
|
||||
Some(p) => Ok(Json(PresetDto::from(&p))),
|
||||
None => Err(StatusCode::NOT_FOUND),
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_preset<C, E>(State(state): S<C, E>, Json(body): Json<CreatePresetDto>) -> Result<StatusCode, (StatusCode, String)>
|
||||
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug,
|
||||
{
|
||||
let preset = body.into_domain().map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
||||
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
|
||||
svc.save_preset(preset).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
|
||||
Ok(StatusCode::CREATED)
|
||||
}
|
||||
|
||||
async fn delete_preset<C, E>(State(state): S<C, E>, Path(id): Path<u16>) -> Result<StatusCode, StatusCode>
|
||||
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug,
|
||||
{
|
||||
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
|
||||
svc.delete_preset(id).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn load_preset<C, E>(State(state): S<C, E>, Path(id): Path<u16>) -> Result<StatusCode, (StatusCode, String)>
|
||||
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug,
|
||||
{
|
||||
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
|
||||
svc.load_preset(id).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
54
crates/adapters/http-api/src/routes/data_sources.rs
Normal file
54
crates/adapters/http-api/src/routes/data_sources.rs
Normal file
@@ -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<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,
|
||||
{
|
||||
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,
|
||||
{
|
||||
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,
|
||||
{
|
||||
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<C, E>(State(state): S<C, E>, Path(_id): Path<u16>, Json(body): Json<DataSourceDto>) -> Result<StatusCode, (StatusCode, String)>
|
||||
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug,
|
||||
{
|
||||
let source = body.into_domain().map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
||||
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
|
||||
svc.update_data_source(source).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
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)?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
27
crates/adapters/http-api/src/routes/layout.rs
Normal file
27
crates/adapters/http-api/src/routes/layout.rs
Normal file
@@ -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<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,
|
||||
{
|
||||
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,
|
||||
{
|
||||
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)
|
||||
}
|
||||
27
crates/adapters/http-api/src/routes/mod.rs
Normal file
27
crates/adapters/http-api/src/routes/mod.rs
Normal file
@@ -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<C, E>() -> Router<AppState<C, E>>
|
||||
where
|
||||
C: ConfigRepository + Send + Sync + 'static,
|
||||
C::Error: std::fmt::Debug + Send,
|
||||
E: EventPublisher + Send + Sync + 'static,
|
||||
E::Error: std::fmt::Debug + Send,
|
||||
{
|
||||
Router::new()
|
||||
.route("/widgets", get(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>))
|
||||
}
|
||||
53
crates/adapters/http-api/src/routes/presets.rs
Normal file
53
crates/adapters/http-api/src/routes/presets.rs
Normal file
@@ -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<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,
|
||||
{
|
||||
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,
|
||||
{
|
||||
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,
|
||||
{
|
||||
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<C, E>(State(state): S<C, E>, Path(id): Path<u16>) -> Result<StatusCode, StatusCode>
|
||||
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug,
|
||||
{
|
||||
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
|
||||
svc.delete_preset(id).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
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}")))?;
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
54
crates/adapters/http-api/src/routes/widgets.rs
Normal file
54
crates/adapters/http-api/src/routes/widgets.rs
Normal file
@@ -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<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,
|
||||
{
|
||||
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,
|
||||
{
|
||||
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,
|
||||
{
|
||||
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<C, E>(State(state): S<C, E>, Path(_id): Path<u16>, Json(body): Json<CreateWidgetDto>) -> Result<StatusCode, (StatusCode, String)>
|
||||
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug,
|
||||
{
|
||||
let widget = body.into_domain().map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
||||
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
|
||||
svc.update_widget(widget).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
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)?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
16
crates/adapters/media/src/error.rs
Normal file
16
crates/adapters/media/src/error.rs
Normal file
@@ -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}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
77
crates/adapters/media/tests/media_tests.rs
Normal file
77
crates/adapters/media/tests/media_tests.rs
Normal file
@@ -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)));
|
||||
}
|
||||
16
crates/adapters/rss/src/error.rs
Normal file
16
crates/adapters/rss/src/error.rs
Normal file
@@ -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}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Value, RssError> {
|
||||
let mut reader = Reader::from_str(xml);
|
||||
let mut items: Vec<Value> = Vec::new();
|
||||
let mut current_item: Option<BTreeMap<String, Value>> = 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#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>Test Feed</title>
|
||||
<link>https://example.com</link>
|
||||
<item>
|
||||
<title>First Article</title>
|
||||
<description>Description of first article</description>
|
||||
<link>https://example.com/1</link>
|
||||
</item>
|
||||
<item>
|
||||
<title>Second Article</title>
|
||||
<description>Description of second</description>
|
||||
<link>https://example.com/2</link>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>"#;
|
||||
|
||||
#[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)
|
||||
}
|
||||
}
|
||||
|
||||
74
crates/adapters/rss/src/parser.rs
Normal file
74
crates/adapters/rss/src/parser.rs
Normal file
@@ -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<Value, RssError> {
|
||||
let mut reader = Reader::from_str(xml);
|
||||
let mut items: Vec<Value> = Vec::new();
|
||||
let mut current_item: Option<BTreeMap<String, Value>> = 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))
|
||||
}
|
||||
30
crates/adapters/rss/tests/parser_tests.rs
Normal file
30
crates/adapters/rss/tests/parser_tests.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
use domain::Value;
|
||||
use rss_adapter::{parse_rss, RssError};
|
||||
|
||||
const SAMPLE_RSS: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>Test Feed</title>
|
||||
<link>https://example.com</link>
|
||||
<item>
|
||||
<title>First Article</title>
|
||||
<description>Description of first article</description>
|
||||
<link>https://example.com/1</link>
|
||||
</item>
|
||||
<item>
|
||||
<title>Second Article</title>
|
||||
<description>Description of second</description>
|
||||
<link>https://example.com/2</link>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>"#;
|
||||
|
||||
#[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())));
|
||||
}
|
||||
76
crates/adapters/tcp-server/src/broadcaster.rs
Normal file
76
crates/adapters/tcp-server/src/broadcaster.rs
Normal file
@@ -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<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl TcpBroadcaster {
|
||||
pub fn new(capacity: usize) -> Self {
|
||||
let (tx, _) = broadcast::channel(capacity);
|
||||
Self { tx }
|
||||
}
|
||||
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<Vec<u8>> {
|
||||
self.tx.subscribe()
|
||||
}
|
||||
|
||||
fn send_frame(&self, frame: Vec<u8>) -> 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<WidgetDescriptor> = 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<WidgetDescriptor> = 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)
|
||||
}
|
||||
}
|
||||
14
crates/adapters/tcp-server/src/error.rs
Normal file
14
crates/adapters/tcp-server/src/error.rs
Normal file
@@ -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}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
27
crates/adapters/tcp-server/src/event_bus.rs
Normal file
27
crates/adapters/tcp-server/src/event_bus.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
use tokio::sync::broadcast;
|
||||
use domain::{EventPublisher, DomainEvent};
|
||||
use crate::error::TcpServerError;
|
||||
|
||||
pub struct TcpEventBus {
|
||||
tx: broadcast::Sender<DomainEvent>,
|
||||
}
|
||||
|
||||
impl TcpEventBus {
|
||||
pub fn new(capacity: usize) -> Self {
|
||||
let (tx, _) = broadcast::channel(capacity);
|
||||
Self { tx }
|
||||
}
|
||||
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<DomainEvent> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -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<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl TcpBroadcaster {
|
||||
pub fn new(capacity: usize) -> Self {
|
||||
let (tx, _) = broadcast::channel(capacity);
|
||||
Self { tx }
|
||||
}
|
||||
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<Vec<u8>> {
|
||||
self.tx.subscribe()
|
||||
}
|
||||
|
||||
fn send_frame(&self, frame: Vec<u8>) -> 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<WidgetDescriptor> = 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<WidgetDescriptor> = 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<DomainEvent>,
|
||||
}
|
||||
|
||||
impl TcpEventBus {
|
||||
pub fn new(capacity: usize) -> Self {
|
||||
let (tx, _) = broadcast::channel(capacity);
|
||||
Self { tx }
|
||||
}
|
||||
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<DomainEvent> {
|
||||
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<TcpBroadcaster>,
|
||||
) -> 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;
|
||||
|
||||
38
crates/adapters/tcp-server/src/server.rs
Normal file
38
crates/adapters/tcp-server/src/server.rs
Normal file
@@ -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<TcpBroadcaster>,
|
||||
) -> 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user