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 ,
- 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, 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,
+ 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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 (
+ 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