#[cfg(feature = "rss")]
pub mod cache;
pub mod config;
#[cfg(feature = "rss")]
pub mod conformance;
#[cfg(feature = "rss")]
pub mod convert;
#[cfg(all(test, feature = "rss"))]
mod corpus;
pub mod error;
#[cfg(feature = "rss")]
pub mod opml;
#[cfg(feature = "rss")]
mod egress;
#[cfg(feature = "rss")]
pub mod exec;
#[cfg(feature = "rss")]
pub mod engine;
#[cfg(feature = "rss")]
mod fetch;
#[cfg(all(test, feature = "rss"))]
pub(crate) mod testutil;
#[cfg(feature = "rss")]
pub mod parse;
#[cfg(feature = "rss")]
pub mod sanitize;
#[cfg(feature = "rss")]
pub mod schema;
#[cfg(feature = "rss")]
pub mod table;
#[cfg(all(test, feature = "rss"))]
mod integration_tests;
#[cfg(all(test, feature = "rss", feature = "chunking"))]
mod composition_tests;
pub use config::{FeedSubscription, RssConfig};
pub use error::RssError;
#[cfg(feature = "rss")]
pub use opml::resolve_subscriptions;
#[cfg(feature = "rss")]
pub use egress::{AllowAll, EgressDenied, EgressPolicy, EgressReason};
#[cfg(feature = "rss")]
use std::sync::Arc;
#[cfg(feature = "rss")]
use anyhow::Result;
#[cfg(feature = "rss")]
use datafusion::catalog::{
CatalogProvider, MemoryCatalogProvider, MemorySchemaProvider, SchemaProvider,
};
#[cfg(feature = "rss")]
use datafusion::prelude::SessionContext;
#[cfg(feature = "rss")]
use crate::sources::hierarchy::HierarchyLevel;
#[cfg(feature = "rss")]
use engine::RssEngine;
#[cfg(feature = "rss")]
use table::RssTableProvider;
pub const RSS_SURFACE_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedSubscription {
pub name: String,
pub url: String,
}
#[cfg(feature = "rss")]
const RSS_SCHEMA: &str = "main";
#[cfg(feature = "rss")]
const FEEDS_TABLE: &str = "feeds";
#[cfg(feature = "rss")]
const ITEMS_TABLE: &str = "items";
#[cfg(feature = "rss")]
pub async fn register_rss_tables(
session_ctx: &mut SessionContext,
name: &str,
config: Option<&RssConfig>,
read_write: bool,
hierarchy_level: HierarchyLevel,
) -> Result<()> {
register_with_policy(session_ctx, name, config, read_write, hierarchy_level, None).await
}
#[cfg(feature = "rss")]
pub async fn register_rss_tables_with_policy(
session_ctx: &mut SessionContext,
name: &str,
config: Option<&RssConfig>,
read_write: bool,
hierarchy_level: HierarchyLevel,
policy: Arc<dyn EgressPolicy>,
) -> Result<()> {
register_with_policy(
session_ctx,
name,
config,
read_write,
hierarchy_level,
Some(policy),
)
.await
}
#[cfg(feature = "rss")]
async fn register_with_policy(
session_ctx: &mut SessionContext,
name: &str,
config: Option<&RssConfig>,
read_write: bool,
hierarchy_level: HierarchyLevel,
policy: Option<Arc<dyn EgressPolicy>>,
) -> Result<()> {
if hierarchy_level != HierarchyLevel::Catalog {
return Err(RssError::CatalogHierarchyRequired {
name: name.to_string(),
}
.into());
}
if read_write {
return Err(RssError::ReadWriteNotSupported {
name: name.to_string(),
}
.into());
}
let config = config.ok_or_else(|| RssError::MissingConfig {
name: name.to_string(),
})?;
config.validate()?;
let subscriptions = resolve_subscriptions(name, config)?;
let engine = Arc::new(RssEngine::new(
name.to_string(),
subscriptions,
config,
policy,
)?);
let subscription_count = engine.subscriptions().len();
let schema_provider = Arc::new(MemorySchemaProvider::new());
schema_provider
.register_table(
FEEDS_TABLE.to_string(),
Arc::new(RssTableProvider::feeds(Arc::clone(&engine))),
)
.map_err(|e| {
anyhow::anyhow!(
"rss source '{name}': failed to register {RSS_SCHEMA}.{FEEDS_TABLE}: {e}"
)
})?;
schema_provider
.register_table(
ITEMS_TABLE.to_string(),
Arc::new(RssTableProvider::items(engine)),
)
.map_err(|e| {
anyhow::anyhow!(
"rss source '{name}': failed to register {RSS_SCHEMA}.{ITEMS_TABLE}: {e}"
)
})?;
let catalog = Arc::new(MemoryCatalogProvider::new());
catalog
.register_schema(RSS_SCHEMA, schema_provider)
.map_err(|e| {
anyhow::anyhow!("rss source '{name}': failed to register schema '{RSS_SCHEMA}': {e}")
})?;
session_ctx.register_catalog(name, catalog);
tracing::info!(
source = %name,
subscriptions = subscription_count,
surface_version = RSS_SURFACE_VERSION,
"RSS source registered"
);
Ok(())
}
#[cfg(all(test, feature = "rss"))]
mod tests {
use super::*;
use crate::sources::providers::rss::config::inline_config;
use crate::sources::providers::rss::schema::{feeds_schema, items_schema};
use crate::sources::providers::rss::testutil::{
MockFeedServer, MockResponse, MockResponseExt, RSS2_MINIMAL, feed_urls, str_col,
};
use arrow::array::RecordBatch;
fn config_pointing_at(server: &MockFeedServer, feeds: &[(&str, &str)]) -> RssConfig {
let mut config = inline_config(
feed_urls(server, feeds)
.into_iter()
.map(|(name, url)| FeedSubscription {
url,
name: Some(name),
})
.collect(),
);
config.request_timeout_seconds = 5;
config.scan_timeout_seconds = 10;
config
}
fn unreachable_config() -> RssConfig {
inline_config(vec![FeedSubscription {
url: "https://feed.example/f.xml".to_string(),
name: Some("a".to_string()),
}])
}
async fn query(ctx: &SessionContext, sql: &str) -> Vec<RecordBatch> {
ctx.sql(sql)
.await
.unwrap_or_else(|e| panic!("plan {sql:?}: {e}"))
.collect()
.await
.unwrap_or_else(|e| panic!("execute {sql:?}: {e}"))
}
#[tokio::test]
async fn registration_is_zero_network_and_tables_queryable() {
let server = MockFeedServer::start(|_| MockResponse::xml(RSS2_MINIMAL)).await;
let mut ctx = SessionContext::new();
let config = config_pointing_at(&server, &[("a", "/f.xml")]);
register_rss_tables_with_policy(
&mut ctx,
"news",
Some(&config),
false,
HierarchyLevel::Catalog,
Arc::new(AllowAll),
)
.await
.expect("registration succeeds");
assert_eq!(
server.requests().len(),
0,
"registration performed network I/O"
);
let feeds = query(
&ctx,
"SELECT name, last_status FROM news.main.feeds ORDER BY name",
)
.await;
assert_eq!(str_col(&feeds[0], "name"), vec!["a"]);
assert_eq!(str_col(&feeds[0], "last_status"), vec!["never"]);
assert_eq!(
server.requests().len(),
0,
"feeds scan performed network I/O"
);
let items = query(&ctx, "SELECT guid, window_status FROM news.main.items").await;
assert_eq!(
items.iter().map(RecordBatch::num_rows).sum::<usize>(),
1,
"the one item in RSS2_MINIMAL"
);
assert_eq!(
server.requests().len(),
1,
"an items scan is what fetches the feed"
);
}
#[tokio::test]
async fn non_catalog_hierarchy_is_rejected() {
let mut ctx = SessionContext::new();
let config = unreachable_config();
let err = register_rss_tables(
&mut ctx,
"news",
Some(&config),
false,
HierarchyLevel::Table,
)
.await
.expect_err("hierarchy_level: table must be rejected");
assert!(
err.to_string()
.contains("hierarchy_level must be 'catalog'"),
"{err}"
);
assert!(
ctx.catalog("news").is_none(),
"a rejected source must leave no catalog behind"
);
}
#[tokio::test]
async fn read_write_is_rejected() {
let mut ctx = SessionContext::new();
let config = unreachable_config();
let err = register_rss_tables(
&mut ctx,
"news",
Some(&config),
true,
HierarchyLevel::Catalog,
)
.await
.expect_err("read_write must be rejected");
assert!(
err.to_string().contains("access_mode must be read-only"),
"{err}"
);
assert!(
ctx.catalog("news").is_none(),
"a rejected source must leave no catalog behind"
);
}
#[tokio::test]
async fn missing_config_is_rejected() {
let mut ctx = SessionContext::new();
let err = register_rss_tables(&mut ctx, "news", None, false, HierarchyLevel::Catalog)
.await
.expect_err("a source with no `rss:` block must be rejected");
assert!(
err.to_string()
.contains("missing required `rss:` configuration block"),
"{err}"
);
assert!(
ctx.catalog("news").is_none(),
"a rejected source must leave no catalog behind"
);
}
#[tokio::test]
async fn an_invalid_config_is_rejected_before_any_catalog_appears() {
let mut ctx = SessionContext::new();
let mut config = unreachable_config();
config.max_concurrent = 0;
let err = register_rss_tables(
&mut ctx,
"news",
Some(&config),
false,
HierarchyLevel::Catalog,
)
.await
.expect_err("an invalid config must be rejected");
assert!(
err.to_string()
.contains("max_concurrent must be at least 1"),
"{err}"
);
assert!(
ctx.catalog("news").is_none(),
"a rejected source must leave no catalog behind"
);
}
#[test]
fn schema_metadata_carries_surface_version() {
for schema in [items_schema(), feeds_schema()] {
assert_eq!(
schema
.metadata()
.get("skardi.rss.surface_version")
.map(String::as_str),
Some("1"),
);
}
assert_eq!(RSS_SURFACE_VERSION, 1);
}
}