add config-sqlite and http-api adapters

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

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

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

View File

@@ -0,0 +1,262 @@
mod serialization;
use std::time::Duration;
use sqlx::{SqlitePool, Row};
use domain::{
ConfigRepository,
DataSource, DataSourceId, DataSourceConfig, DataSourceType,
Layout, LayoutPreset, LayoutPresetId,
WidgetConfig, WidgetId,
};
use serialization as ser;
#[derive(Debug)]
pub enum SqliteConfigError {
Sql(sqlx::Error),
Serialization(String),
}
impl std::fmt::Display for SqliteConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SqliteConfigError::Sql(e) => write!(f, "sql: {e}"),
SqliteConfigError::Serialization(e) => write!(f, "serialization: {e}"),
}
}
}
pub struct SqliteConfigStore {
pool: SqlitePool,
}
impl SqliteConfigStore {
pub async fn new(database_url: &str) -> Result<Self, sqlx::Error> {
let pool = SqlitePool::connect(database_url).await?;
let store = Self { pool };
store.migrate().await?;
Ok(store)
}
async fn migrate(&self) -> Result<(), sqlx::Error> {
sqlx::query(
"CREATE TABLE IF NOT EXISTS widgets (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
display_hint TEXT NOT NULL,
data_source_id INTEGER NOT NULL,
mappings TEXT NOT NULL,
max_data_size INTEGER NOT NULL
)"
).execute(&self.pool).await?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS data_sources (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
source_type TEXT NOT NULL,
poll_interval_secs INTEGER NOT NULL,
config TEXT NOT NULL
)"
).execute(&self.pool).await?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS layout (
id INTEGER PRIMARY KEY CHECK (id = 1),
data TEXT NOT NULL
)"
).execute(&self.pool).await?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS presets (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
layout_data TEXT NOT NULL
)"
).execute(&self.pool).await?;
Ok(())
}
}
impl ConfigRepository for SqliteConfigStore {
type Error = SqliteConfigError;
async fn get_widget(&self, id: WidgetId) -> Result<Option<WidgetConfig>, Self::Error> {
let row = sqlx::query("SELECT * FROM widgets WHERE id = ?")
.bind(id as i64)
.fetch_optional(&self.pool)
.await
.map_err(SqliteConfigError::Sql)?;
match row {
None => Ok(None),
Some(row) => Ok(Some(ser::widget_from_row(&row)?)),
}
}
async fn list_widgets(&self) -> Result<Vec<WidgetConfig>, Self::Error> {
let rows = sqlx::query("SELECT * FROM widgets")
.fetch_all(&self.pool)
.await
.map_err(SqliteConfigError::Sql)?;
rows.iter().map(|r| ser::widget_from_row(r)).collect()
}
async fn save_widget(&self, config: &WidgetConfig) -> Result<(), Self::Error> {
let mappings_json = ser::mappings_to_json(&config.mappings)?;
let hint_str = ser::display_hint_to_str(&config.display_hint);
sqlx::query(
"INSERT OR REPLACE INTO widgets (id, name, display_hint, data_source_id, mappings, max_data_size)
VALUES (?, ?, ?, ?, ?, ?)"
)
.bind(config.id as i64)
.bind(&config.name)
.bind(hint_str)
.bind(config.data_source_id as i64)
.bind(&mappings_json)
.bind(config.max_data_size as i64)
.execute(&self.pool)
.await
.map_err(SqliteConfigError::Sql)?;
Ok(())
}
async fn delete_widget(&self, id: WidgetId) -> Result<(), Self::Error> {
sqlx::query("DELETE FROM widgets WHERE id = ?")
.bind(id as i64)
.execute(&self.pool)
.await
.map_err(SqliteConfigError::Sql)?;
Ok(())
}
async fn get_data_source(&self, id: DataSourceId) -> Result<Option<DataSource>, Self::Error> {
let row = sqlx::query("SELECT * FROM data_sources WHERE id = ?")
.bind(id as i64)
.fetch_optional(&self.pool)
.await
.map_err(SqliteConfigError::Sql)?;
match row {
None => Ok(None),
Some(row) => Ok(Some(ser::data_source_from_row(&row)?)),
}
}
async fn list_data_sources(&self) -> Result<Vec<DataSource>, Self::Error> {
let rows = sqlx::query("SELECT * FROM data_sources")
.fetch_all(&self.pool)
.await
.map_err(SqliteConfigError::Sql)?;
rows.iter().map(|r| ser::data_source_from_row(r)).collect()
}
async fn save_data_source(&self, source: &DataSource) -> Result<(), Self::Error> {
let config_json = ser::data_source_config_to_json(&source.config)?;
let type_str = ser::data_source_type_to_str(&source.source_type);
sqlx::query(
"INSERT OR REPLACE INTO data_sources (id, name, source_type, poll_interval_secs, config)
VALUES (?, ?, ?, ?, ?)"
)
.bind(source.id as i64)
.bind(&source.name)
.bind(type_str)
.bind(source.poll_interval.as_secs() as i64)
.bind(&config_json)
.execute(&self.pool)
.await
.map_err(SqliteConfigError::Sql)?;
Ok(())
}
async fn delete_data_source(&self, id: DataSourceId) -> Result<(), Self::Error> {
sqlx::query("DELETE FROM data_sources WHERE id = ?")
.bind(id as i64)
.execute(&self.pool)
.await
.map_err(SqliteConfigError::Sql)?;
Ok(())
}
async fn get_layout(&self) -> Result<Option<Layout>, Self::Error> {
let row = sqlx::query("SELECT data FROM layout WHERE id = 1")
.fetch_optional(&self.pool)
.await
.map_err(SqliteConfigError::Sql)?;
match row {
None => Ok(None),
Some(row) => {
let json: String = row.get("data");
Ok(Some(ser::layout_from_json(&json)?))
}
}
}
async fn save_layout(&self, layout: &Layout) -> Result<(), Self::Error> {
let json = ser::layout_to_json(layout)?;
sqlx::query(
"INSERT OR REPLACE INTO layout (id, data) VALUES (1, ?)"
)
.bind(&json)
.execute(&self.pool)
.await
.map_err(SqliteConfigError::Sql)?;
Ok(())
}
async fn get_preset(&self, id: LayoutPresetId) -> Result<Option<LayoutPreset>, Self::Error> {
let row = sqlx::query("SELECT * FROM presets WHERE id = ?")
.bind(id as i64)
.fetch_optional(&self.pool)
.await
.map_err(SqliteConfigError::Sql)?;
match row {
None => Ok(None),
Some(row) => Ok(Some(ser::preset_from_row(&row)?)),
}
}
async fn list_presets(&self) -> Result<Vec<LayoutPreset>, Self::Error> {
let rows = sqlx::query("SELECT * FROM presets")
.fetch_all(&self.pool)
.await
.map_err(SqliteConfigError::Sql)?;
rows.iter().map(|r| ser::preset_from_row(r)).collect()
}
async fn save_preset(&self, preset: &LayoutPreset) -> Result<(), Self::Error> {
let layout_json = ser::layout_to_json(&preset.layout)?;
sqlx::query(
"INSERT OR REPLACE INTO presets (id, name, layout_data) VALUES (?, ?, ?)"
)
.bind(preset.id as i64)
.bind(&preset.name)
.bind(&layout_json)
.execute(&self.pool)
.await
.map_err(SqliteConfigError::Sql)?;
Ok(())
}
async fn delete_preset(&self, id: LayoutPresetId) -> Result<(), Self::Error> {
sqlx::query("DELETE FROM presets WHERE id = ?")
.bind(id as i64)
.execute(&self.pool)
.await
.map_err(SqliteConfigError::Sql)?;
Ok(())
}
}