add http-json, rss, and media data source adapters
http-json: generic HTTP+JSON polling adapter, converts serde_json to domain Value. 4 tests. rss: XML RSS feed parser, extracts items into Value array. 1 test. media: Navidrome/Subsonic getNowPlaying adapter. 2 tests with fake server.
This commit is contained in:
13
crates/adapters/http-json/Cargo.toml
Normal file
13
crates/adapters/http-json/Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "http-json"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
domain.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio.workspace = true
|
||||
axum.workspace = true
|
||||
68
crates/adapters/http-json/src/lib.rs
Normal file
68
crates/adapters/http-json/src/lib.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
use domain::{DataSource, DataSourcePort, Value};
|
||||
|
||||
pub struct HttpJsonAdapter {
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum HttpJsonError {
|
||||
Request(reqwest::Error),
|
||||
NoUrl,
|
||||
Parse(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for HttpJsonError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
HttpJsonError::Request(e) => write!(f, "request: {e}"),
|
||||
HttpJsonError::NoUrl => write!(f, "no url configured"),
|
||||
HttpJsonError::Parse(e) => write!(f, "parse: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HttpJsonAdapter {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
client: reqwest::Client::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn json_to_value(json: serde_json::Value) -> Value {
|
||||
match json {
|
||||
serde_json::Value::Null => Value::Null,
|
||||
serde_json::Value::Bool(b) => Value::Bool(b),
|
||||
serde_json::Value::Number(n) => Value::Number(n.as_f64().unwrap_or(0.0)),
|
||||
serde_json::Value::String(s) => Value::String(s),
|
||||
serde_json::Value::Array(arr) => {
|
||||
Value::Array(arr.into_iter().map(json_to_value).collect())
|
||||
}
|
||||
serde_json::Value::Object(map) => {
|
||||
Value::Object(map.into_iter().map(|(k, v)| (k, json_to_value(v))).collect())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DataSourcePort for HttpJsonAdapter {
|
||||
type Error = HttpJsonError;
|
||||
|
||||
async fn poll(&self, source: &DataSource) -> Result<Value, Self::Error> {
|
||||
let url = source.config.url.as_ref().ok_or(HttpJsonError::NoUrl)?;
|
||||
|
||||
let mut req = self.client.get(url);
|
||||
|
||||
for (key, val) in &source.config.headers {
|
||||
req = req.header(key, val);
|
||||
}
|
||||
|
||||
if let Some(api_key) = &source.config.api_key {
|
||||
req = req.header("Authorization", format!("Bearer {api_key}"));
|
||||
}
|
||||
|
||||
let resp = req.send().await.map_err(HttpJsonError::Request)?;
|
||||
let json: serde_json::Value = resp.json().await.map_err(HttpJsonError::Request)?;
|
||||
|
||||
Ok(json_to_value(json))
|
||||
}
|
||||
}
|
||||
102
crates/adapters/http-json/tests/http_json_tests.rs
Normal file
102
crates/adapters/http-json/tests/http_json_tests.rs
Normal file
@@ -0,0 +1,102 @@
|
||||
use std::time::Duration;
|
||||
use axum::{Router, routing::get, response::Json};
|
||||
use domain::{DataSource, DataSourceConfig, DataSourcePort, DataSourceType, Value};
|
||||
use http_json::HttpJsonAdapter;
|
||||
|
||||
async fn start_fake_api() -> String {
|
||||
let app = Router::new()
|
||||
.route("/weather", get(|| async {
|
||||
Json(serde_json::json!({
|
||||
"main": {"temp": 5.4, "humidity": 80},
|
||||
"weather": [{"icon": "cloud_rain"}]
|
||||
}))
|
||||
}))
|
||||
.route("/simple", get(|| async {
|
||||
Json(serde_json::json!({"value": "hello"}))
|
||||
}))
|
||||
.route("/not-json", get(|| async { "plain text" }));
|
||||
|
||||
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: "test".into(),
|
||||
source_type: DataSourceType::HttpJson,
|
||||
poll_interval: Duration::from_secs(60),
|
||||
config: DataSourceConfig {
|
||||
url: Some(url),
|
||||
headers: vec![],
|
||||
api_key: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn polls_url_and_returns_nested_json_as_value() {
|
||||
let base = start_fake_api().await;
|
||||
let adapter = HttpJsonAdapter::new();
|
||||
let source = make_source(format!("{base}/weather"));
|
||||
|
||||
let result = adapter.poll(&source).await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
result.get_path("$.main.temp"),
|
||||
Some(&Value::Number(5.4))
|
||||
);
|
||||
assert_eq!(
|
||||
result.get_path("$.main.humidity"),
|
||||
Some(&Value::Number(80.0))
|
||||
);
|
||||
assert_eq!(
|
||||
result.get_path("$.weather[0].icon"),
|
||||
Some(&Value::String("cloud_rain".into()))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn polls_simple_json() {
|
||||
let base = start_fake_api().await;
|
||||
let adapter = HttpJsonAdapter::new();
|
||||
let source = make_source(format!("{base}/simple"));
|
||||
|
||||
let result = adapter.poll(&source).await.unwrap();
|
||||
assert_eq!(
|
||||
result.get_path("$.value"),
|
||||
Some(&Value::String("hello".into()))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_error_when_no_url() {
|
||||
let adapter = HttpJsonAdapter::new();
|
||||
let source = DataSource {
|
||||
id: 1,
|
||||
name: "bad".into(),
|
||||
source_type: DataSourceType::HttpJson,
|
||||
poll_interval: Duration::from_secs(60),
|
||||
config: DataSourceConfig {
|
||||
url: None,
|
||||
headers: vec![],
|
||||
api_key: None,
|
||||
},
|
||||
};
|
||||
|
||||
let result = adapter.poll(&source).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_error_on_connection_refused() {
|
||||
let adapter = HttpJsonAdapter::new();
|
||||
let source = make_source("http://127.0.0.1:1".into());
|
||||
|
||||
let result = adapter.poll(&source).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
Reference in New Issue
Block a user