diff --git a/rsworkspace/crates/trogon-gateway/src/http.rs b/rsworkspace/crates/trogon-gateway/src/http.rs index 6e71e6e1a..7a5bf4bf8 100644 --- a/rsworkspace/crates/trogon-gateway/src/http.rs +++ b/rsworkspace/crates/trogon-gateway/src/http.rs @@ -1,15 +1,15 @@ use axum::Router; -use tracing::info; use trogon_nats::jetstream::{ClaimCheckPublisher, JetStreamPublisher, ObjectStorePut}; -use crate::config::{ResolvedConfig, SourceIntegration}; +use crate::config::ResolvedConfig; +use crate::source_plugin; pub(crate) fn mount_sources(config: ResolvedConfig, publisher: ClaimCheckPublisher) -> Router where P: JetStreamPublisher, S: ObjectStorePut, { - let mut app = Router::new() + let app = Router::new() .route( "/-/liveness", axum::routing::get(|| async { axum::http::StatusCode::OK }), @@ -19,119 +19,7 @@ where axum::routing::get(|| async { axum::http::StatusCode::OK }), ); - app = mount_webhook_integrations( - app, - "github", - "/sources/github", - &config.github, - publisher.clone(), - |p, cfg| crate::source::github::router(p, cfg), - ); - for integration in &config.slack { - if integration.config.webhook().is_none() { - continue; - } - let path = format!("/sources/slack/{}", integration.id); - app = app.nest( - &path, - crate::source::slack::router(publisher.clone(), &integration.config), - ); - let integration_id = integration.id.as_str(); - info!( - source = "slack", - integration = integration_id, - path, - "mounted source integration" - ); - } - app = mount_webhook_integrations( - app, - "telegram", - "/sources/telegram", - &config.telegram, - publisher.clone(), - |p, cfg| crate::source::telegram::router(p, cfg), - ); - app = mount_webhook_integrations( - app, - "twitter", - "/sources/twitter", - &config.twitter, - publisher.clone(), - |p, cfg| crate::source::twitter::router(p, cfg), - ); - app = mount_webhook_integrations( - app, - "gitlab", - "/sources/gitlab", - &config.gitlab, - publisher.clone(), - |p, cfg| crate::source::gitlab::router(p, cfg), - ); - app = mount_webhook_integrations( - app, - "incidentio", - "/sources/incidentio", - &config.incidentio, - publisher.clone(), - |p, cfg| crate::source::incidentio::router(p, cfg), - ); - app = mount_webhook_integrations( - app, - "linear", - "/sources/linear", - &config.linear, - publisher.clone(), - |p, cfg| crate::source::linear::router(p, cfg), - ); - app = mount_webhook_integrations( - app, - "microsoft-graph", - "/sources/microsoft-graph", - &config.microsoft_graph, - publisher.clone(), - |p, cfg| crate::source::microsoft_graph::router(p, cfg), - ); - app = mount_webhook_integrations( - app, - "notion", - "/sources/notion", - &config.notion, - publisher.clone(), - |p, cfg| crate::source::notion::router(p, cfg), - ); - app = mount_webhook_integrations(app, "sentry", "/sources/sentry", &config.sentry, publisher, |p, cfg| { - crate::source::sentry::router(p, cfg) - }); - - app -} - -fn mount_webhook_integrations( - mut app: Router, - source: &'static str, - source_path: &'static str, - integrations: &[SourceIntegration], - publisher: ClaimCheckPublisher, - router: F, -) -> Router -where - P: JetStreamPublisher, - S: ObjectStorePut, - F: Fn(ClaimCheckPublisher, &C) -> Router, -{ - for integration in integrations { - let path = format!("{}/{}", source_path, integration.id); - app = app.nest(&path, router(publisher.clone(), &integration.config)); - info!( - source, - integration = integration.id.as_str(), - path, - "mounted source integration" - ); - } - - app + source_plugin::mount_webhook_sources(app, publisher, &config) } #[cfg(test)] diff --git a/rsworkspace/crates/trogon-gateway/src/main.rs b/rsworkspace/crates/trogon-gateway/src/main.rs index 732935ffa..70a4c0782 100644 --- a/rsworkspace/crates/trogon-gateway/src/main.rs +++ b/rsworkspace/crates/trogon-gateway/src/main.rs @@ -11,6 +11,8 @@ mod source; #[cfg_attr(coverage, allow(dead_code))] mod source_integration_id; #[cfg_attr(coverage, allow(dead_code))] +mod source_plugin; +#[cfg_attr(coverage, allow(dead_code))] mod source_status; #[cfg_attr(coverage, allow(dead_code))] mod streams; diff --git a/rsworkspace/crates/trogon-gateway/src/source_plugin.rs b/rsworkspace/crates/trogon-gateway/src/source_plugin.rs new file mode 100644 index 000000000..6c500955a --- /dev/null +++ b/rsworkspace/crates/trogon-gateway/src/source_plugin.rs @@ -0,0 +1,401 @@ +//! Source plugin trait. +//! +//! Each gateway-managed webhook source implements `SourcePlugin` so the +//! gateway can iterate sources without duplicating the per-source plumbing +//! in `http.rs` and `streams.rs`. The same plugin owns both provisioning +//! (JetStream streams) and HTTP route mounting for its integrations. +//! +//! Discord is intentionally NOT a `SourcePlugin`: its primary path is a +//! WebSocket gateway runner spawned in `main.rs`, not a webhook receiver. +//! Slack's socket-mode runners are spawned the same way — `SlackPlugin` +//! only mounts integrations that expose a webhook config. + +use axum::Router; +use tracing::info; +use trogon_nats::jetstream::{ClaimCheckPublisher, JetStreamContext, JetStreamPublisher, ObjectStorePut}; + +use crate::config::{ResolvedConfig, SourceIntegration}; + +pub type SourceId = &'static str; + +pub trait SourcePlugin: Send + Sync { + fn id(&self) -> SourceId; + + fn path_prefix(&self) -> String { + format!("/sources/{}", self.id()) + } + + async fn provision(&self, client: &C, config: &ResolvedConfig) -> Result<(), C::Error>; + + fn mount(&self, app: Router, publisher: ClaimCheckPublisher, config: &ResolvedConfig) -> Router + where + P: JetStreamPublisher, + S: ObjectStorePut; +} + +pub struct GithubPlugin; +pub struct SlackPlugin; +pub struct TelegramPlugin; +pub struct TwitterPlugin; +pub struct GitlabPlugin; +pub struct IncidentioPlugin; +pub struct LinearPlugin; +pub struct MicrosoftGraphPlugin; +pub struct NotionPlugin; +pub struct SentryPlugin; + +async fn provision_integrations( + integrations: &[SourceIntegration], + source: SourceId, + client: &C, + provision_fn: F, +) -> Result<(), C::Error> +where + C: JetStreamContext, + F: AsyncFn(&C, &T) -> Result<(), C::Error>, +{ + for integration in integrations { + provision_fn(client, &integration.config).await?; + info!(source, integration = integration.id.as_str(), "stream provisioned"); + } + Ok(()) +} + +fn mount_integrations( + integrations: &[SourceIntegration], + mut app: Router, + publisher: ClaimCheckPublisher, + source: SourceId, + path_prefix: &str, + router_fn: F, +) -> Router +where + P: JetStreamPublisher, + S: ObjectStorePut, + F: Fn(ClaimCheckPublisher, &T) -> Router, +{ + for integration in integrations { + let path = format!("{}/{}", path_prefix, integration.id); + app = app.nest(&path, router_fn(publisher.clone(), &integration.config)); + info!( + source, + integration = integration.id.as_str(), + path, + "mounted source integration" + ); + } + app +} + +impl SourcePlugin for GithubPlugin { + fn id(&self) -> SourceId { + "github" + } + + async fn provision(&self, client: &C, config: &ResolvedConfig) -> Result<(), C::Error> { + provision_integrations(&config.github, self.id(), client, crate::source::github::provision).await + } + + fn mount(&self, app: Router, publisher: ClaimCheckPublisher, config: &ResolvedConfig) -> Router + where + P: JetStreamPublisher, + S: ObjectStorePut, + { + mount_integrations( + &config.github, + app, + publisher, + self.id(), + &self.path_prefix(), + |p, cfg| crate::source::github::router(p, cfg), + ) + } +} + +impl SourcePlugin for SlackPlugin { + fn id(&self) -> SourceId { + "slack" + } + + async fn provision(&self, client: &C, config: &ResolvedConfig) -> Result<(), C::Error> { + provision_integrations(&config.slack, self.id(), client, crate::source::slack::provision).await + } + + fn mount(&self, mut app: Router, publisher: ClaimCheckPublisher, config: &ResolvedConfig) -> Router + where + P: JetStreamPublisher, + S: ObjectStorePut, + { + // Socket-mode-only integrations are spawned as long-running runners + // in `main.rs`; they have no HTTP route to mount. + for integration in &config.slack { + if integration.config.webhook().is_none() { + continue; + } + let path = format!("{}/{}", self.path_prefix(), integration.id); + app = app.nest( + &path, + crate::source::slack::router(publisher.clone(), &integration.config), + ); + info!( + source = self.id(), + integration = integration.id.as_str(), + path, + "mounted source integration" + ); + } + app + } +} + +impl SourcePlugin for TelegramPlugin { + fn id(&self) -> SourceId { + "telegram" + } + + async fn provision(&self, client: &C, config: &ResolvedConfig) -> Result<(), C::Error> { + provision_integrations(&config.telegram, self.id(), client, crate::source::telegram::provision).await + } + + fn mount(&self, app: Router, publisher: ClaimCheckPublisher, config: &ResolvedConfig) -> Router + where + P: JetStreamPublisher, + S: ObjectStorePut, + { + mount_integrations( + &config.telegram, + app, + publisher, + self.id(), + &self.path_prefix(), + |p, cfg| crate::source::telegram::router(p, cfg), + ) + } +} + +impl SourcePlugin for TwitterPlugin { + fn id(&self) -> SourceId { + "twitter" + } + + async fn provision(&self, client: &C, config: &ResolvedConfig) -> Result<(), C::Error> { + provision_integrations(&config.twitter, self.id(), client, crate::source::twitter::provision).await + } + + fn mount(&self, app: Router, publisher: ClaimCheckPublisher, config: &ResolvedConfig) -> Router + where + P: JetStreamPublisher, + S: ObjectStorePut, + { + mount_integrations( + &config.twitter, + app, + publisher, + self.id(), + &self.path_prefix(), + |p, cfg| crate::source::twitter::router(p, cfg), + ) + } +} + +impl SourcePlugin for GitlabPlugin { + fn id(&self) -> SourceId { + "gitlab" + } + + async fn provision(&self, client: &C, config: &ResolvedConfig) -> Result<(), C::Error> { + provision_integrations(&config.gitlab, self.id(), client, crate::source::gitlab::provision).await + } + + fn mount(&self, app: Router, publisher: ClaimCheckPublisher, config: &ResolvedConfig) -> Router + where + P: JetStreamPublisher, + S: ObjectStorePut, + { + mount_integrations( + &config.gitlab, + app, + publisher, + self.id(), + &self.path_prefix(), + |p, cfg| crate::source::gitlab::router(p, cfg), + ) + } +} + +impl SourcePlugin for IncidentioPlugin { + fn id(&self) -> SourceId { + "incidentio" + } + + async fn provision(&self, client: &C, config: &ResolvedConfig) -> Result<(), C::Error> { + provision_integrations( + &config.incidentio, + self.id(), + client, + crate::source::incidentio::provision, + ) + .await + } + + fn mount(&self, app: Router, publisher: ClaimCheckPublisher, config: &ResolvedConfig) -> Router + where + P: JetStreamPublisher, + S: ObjectStorePut, + { + mount_integrations( + &config.incidentio, + app, + publisher, + self.id(), + &self.path_prefix(), + |p, cfg| crate::source::incidentio::router(p, cfg), + ) + } +} + +impl SourcePlugin for LinearPlugin { + fn id(&self) -> SourceId { + "linear" + } + + async fn provision(&self, client: &C, config: &ResolvedConfig) -> Result<(), C::Error> { + provision_integrations(&config.linear, self.id(), client, crate::source::linear::provision).await + } + + fn mount(&self, app: Router, publisher: ClaimCheckPublisher, config: &ResolvedConfig) -> Router + where + P: JetStreamPublisher, + S: ObjectStorePut, + { + mount_integrations( + &config.linear, + app, + publisher, + self.id(), + &self.path_prefix(), + |p, cfg| crate::source::linear::router(p, cfg), + ) + } +} + +impl SourcePlugin for MicrosoftGraphPlugin { + fn id(&self) -> SourceId { + "microsoft-graph" + } + + async fn provision(&self, client: &C, config: &ResolvedConfig) -> Result<(), C::Error> { + provision_integrations( + &config.microsoft_graph, + self.id(), + client, + crate::source::microsoft_graph::provision, + ) + .await + } + + fn mount(&self, app: Router, publisher: ClaimCheckPublisher, config: &ResolvedConfig) -> Router + where + P: JetStreamPublisher, + S: ObjectStorePut, + { + mount_integrations( + &config.microsoft_graph, + app, + publisher, + self.id(), + &self.path_prefix(), + |p, cfg| crate::source::microsoft_graph::router(p, cfg), + ) + } +} + +impl SourcePlugin for NotionPlugin { + fn id(&self) -> SourceId { + "notion" + } + + async fn provision(&self, client: &C, config: &ResolvedConfig) -> Result<(), C::Error> { + provision_integrations(&config.notion, self.id(), client, crate::source::notion::provision).await + } + + fn mount(&self, app: Router, publisher: ClaimCheckPublisher, config: &ResolvedConfig) -> Router + where + P: JetStreamPublisher, + S: ObjectStorePut, + { + mount_integrations( + &config.notion, + app, + publisher, + self.id(), + &self.path_prefix(), + |p, cfg| crate::source::notion::router(p, cfg), + ) + } +} + +impl SourcePlugin for SentryPlugin { + fn id(&self) -> SourceId { + "sentry" + } + + async fn provision(&self, client: &C, config: &ResolvedConfig) -> Result<(), C::Error> { + provision_integrations(&config.sentry, self.id(), client, crate::source::sentry::provision).await + } + + fn mount(&self, app: Router, publisher: ClaimCheckPublisher, config: &ResolvedConfig) -> Router + where + P: JetStreamPublisher, + S: ObjectStorePut, + { + mount_integrations( + &config.sentry, + app, + publisher, + self.id(), + &self.path_prefix(), + |p, cfg| crate::source::sentry::router(p, cfg), + ) + } +} + +/// Provision JetStream streams for every webhook source. Discord is provisioned separately by the caller. +pub async fn provision_webhook_sources( + client: &C, + config: &ResolvedConfig, +) -> Result<(), C::Error> { + GithubPlugin.provision(client, config).await?; + SlackPlugin.provision(client, config).await?; + TelegramPlugin.provision(client, config).await?; + TwitterPlugin.provision(client, config).await?; + GitlabPlugin.provision(client, config).await?; + IncidentioPlugin.provision(client, config).await?; + LinearPlugin.provision(client, config).await?; + MicrosoftGraphPlugin.provision(client, config).await?; + NotionPlugin.provision(client, config).await?; + SentryPlugin.provision(client, config).await?; + Ok(()) +} + +/// Mount HTTP routes for every webhook source. +pub fn mount_webhook_sources( + mut app: Router, + publisher: ClaimCheckPublisher, + config: &ResolvedConfig, +) -> Router +where + P: JetStreamPublisher, + S: ObjectStorePut, +{ + app = GithubPlugin.mount(app, publisher.clone(), config); + app = SlackPlugin.mount(app, publisher.clone(), config); + app = TelegramPlugin.mount(app, publisher.clone(), config); + app = TwitterPlugin.mount(app, publisher.clone(), config); + app = GitlabPlugin.mount(app, publisher.clone(), config); + app = IncidentioPlugin.mount(app, publisher.clone(), config); + app = LinearPlugin.mount(app, publisher.clone(), config); + app = MicrosoftGraphPlugin.mount(app, publisher.clone(), config); + app = NotionPlugin.mount(app, publisher.clone(), config); + SentryPlugin.mount(app, publisher, config) +} diff --git a/rsworkspace/crates/trogon-gateway/src/streams.rs b/rsworkspace/crates/trogon-gateway/src/streams.rs index bc4c7014c..e7f2cc833 100644 --- a/rsworkspace/crates/trogon-gateway/src/streams.rs +++ b/rsworkspace/crates/trogon-gateway/src/streams.rs @@ -2,93 +2,15 @@ use tracing::info; use trogon_nats::jetstream::JetStreamContext; use crate::config::ResolvedConfig; +use crate::source_plugin; pub(crate) async fn provision(client: &C, config: &ResolvedConfig) -> Result<(), C::Error> { - for integration in &config.github { - crate::source::github::provision(client, &integration.config).await?; - info!( - source = "github", - integration = integration.id.as_str(), - "stream provisioned" - ); - } + // Discord is gateway-WebSocket, not a webhook source; it doesn't fit `SourcePlugin`. if let Some(ref cfg) = config.discord { crate::source::discord::provision(client, cfg).await?; info!(source = "discord", "stream provisioned"); } - for integration in &config.slack { - crate::source::slack::provision(client, &integration.config).await?; - info!( - source = "slack", - integration = integration.id.as_str(), - "stream provisioned" - ); - } - for integration in &config.telegram { - crate::source::telegram::provision(client, &integration.config).await?; - info!( - source = "telegram", - integration = integration.id.as_str(), - "stream provisioned" - ); - } - for integration in &config.twitter { - crate::source::twitter::provision(client, &integration.config).await?; - info!( - source = "twitter", - integration = integration.id.as_str(), - "stream provisioned" - ); - } - for integration in &config.gitlab { - crate::source::gitlab::provision(client, &integration.config).await?; - info!( - source = "gitlab", - integration = integration.id.as_str(), - "stream provisioned" - ); - } - for integration in &config.incidentio { - crate::source::incidentio::provision(client, &integration.config).await?; - info!( - source = "incidentio", - integration = integration.id.as_str(), - "stream provisioned" - ); - } - for integration in &config.linear { - crate::source::linear::provision(client, &integration.config).await?; - info!( - source = "linear", - integration = integration.id.as_str(), - "stream provisioned" - ); - } - for integration in &config.microsoft_graph { - crate::source::microsoft_graph::provision(client, &integration.config).await?; - info!( - source = "microsoft-graph", - integration = integration.id.as_str(), - "stream provisioned" - ); - } - for integration in &config.notion { - crate::source::notion::provision(client, &integration.config).await?; - info!( - source = "notion", - integration = integration.id.as_str(), - "stream provisioned" - ); - } - for integration in &config.sentry { - crate::source::sentry::provision(client, &integration.config).await?; - info!( - source = "sentry", - integration = integration.id.as_str(), - "stream provisioned" - ); - } - Ok(()) + source_plugin::provision_webhook_sources(client, config).await } #[cfg(test)]