Skip to content

Commit 8eb07fa

Browse files
committed
feat(trogon-gateway): unify Notion ingress
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
1 parent 1afa189 commit 8eb07fa

File tree

17 files changed

+1218
-1
lines changed

17 files changed

+1218
-1
lines changed

devops/docker/compose/.env.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,13 @@
3434
# TROGON_SOURCE_LINEAR_NATS_ACK_TIMEOUT_SECS=10
3535
# TROGON_SOURCE_LINEAR_TIMESTAMP_TOLERANCE_SECS=60
3636

37+
# --- Notion Source ---
38+
# TROGON_SOURCE_NOTION_VERIFICATION_TOKEN=
39+
# TROGON_SOURCE_NOTION_SUBJECT_PREFIX=notion
40+
# TROGON_SOURCE_NOTION_STREAM_NAME=NOTION
41+
# TROGON_SOURCE_NOTION_STREAM_MAX_AGE_SECS=604800
42+
# TROGON_SOURCE_NOTION_NATS_ACK_TIMEOUT_SECS=10
43+
3744
# --- Discord Source ---
3845
# TROGON_SOURCE_DISCORD_BOT_TOKEN=
3946
# TROGON_SOURCE_DISCORD_GATEWAY_INTENTS=guilds,guild_members,guild_messages,guild_message_reactions,direct_messages,message_content,guild_voice_states

devops/docker/compose/compose.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,12 @@ services:
7171
TROGON_SOURCE_LINEAR_STREAM_MAX_AGE_SECS: "${TROGON_SOURCE_LINEAR_STREAM_MAX_AGE_SECS:-604800}"
7272
TROGON_SOURCE_LINEAR_NATS_ACK_TIMEOUT_SECS: "${TROGON_SOURCE_LINEAR_NATS_ACK_TIMEOUT_SECS:-10}"
7373
TROGON_SOURCE_LINEAR_TIMESTAMP_TOLERANCE_SECS: "${TROGON_SOURCE_LINEAR_TIMESTAMP_TOLERANCE_SECS:-60}"
74+
75+
TROGON_SOURCE_NOTION_VERIFICATION_TOKEN: "${TROGON_SOURCE_NOTION_VERIFICATION_TOKEN:-}"
76+
TROGON_SOURCE_NOTION_SUBJECT_PREFIX: "${TROGON_SOURCE_NOTION_SUBJECT_PREFIX:-notion}"
77+
TROGON_SOURCE_NOTION_STREAM_NAME: "${TROGON_SOURCE_NOTION_STREAM_NAME:-NOTION}"
78+
TROGON_SOURCE_NOTION_STREAM_MAX_AGE_SECS: "${TROGON_SOURCE_NOTION_STREAM_MAX_AGE_SECS:-604800}"
79+
TROGON_SOURCE_NOTION_NATS_ACK_TIMEOUT_SECS: "${TROGON_SOURCE_NOTION_NATS_ACK_TIMEOUT_SECS:-10}"
7480
depends_on:
7581
nats:
7682
condition: service_healthy

devops/docker/compose/services/trogon-gateway/README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ prefix:
1515
| GitLab | `/gitlab/webhook` | `TROGON_SOURCE_GITLAB_WEBHOOK_SECRET` |
1616
| incident.io | `/incidentio/webhook` | `TROGON_SOURCE_INCIDENTIO_SIGNING_SECRET` |
1717
| Linear | `/linear/webhook` | `TROGON_SOURCE_LINEAR_WEBHOOK_SECRET` |
18+
| Notion | `/notion/webhook` | `TROGON_SOURCE_NOTION_VERIFICATION_TOKEN` |
1819

1920
The gateway port is configured via `TROGON_GATEWAY_PORT` (default `8080`).
2021
Liveness and readiness probes are available at `GET /-/liveness` and `GET /-/readiness`.
@@ -58,6 +59,14 @@ contain only resource IDs rather than full objects. The gateway forwards the raw
5859
verified payload to NATS and leaves any enrichment or reordering to downstream
5960
consumers.
6061

62+
## Notion webhooks
63+
64+
Notion signs webhook payloads with the subscription `verification_token`.
65+
Configure `TROGON_SOURCE_NOTION_VERIFICATION_TOKEN` before starting the gateway,
66+
then point the Notion webhook endpoint at `/notion/webhook`. Verified events
67+
are forwarded to NATS on `{subject_prefix}.{type}` subjects such as
68+
`notion.page.created`.
69+
6170
## Exposing webhooks with ngrok
6271

6372
```bash

rsworkspace/Cargo.lock

Lines changed: 21 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

rsworkspace/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ trogon-source-github = { path = "crates/trogon-source-github" }
2020
trogon-source-gitlab = { path = "crates/trogon-source-gitlab" }
2121
trogon-source-incidentio = { path = "crates/trogon-source-incidentio" }
2222
trogon-source-linear = { path = "crates/trogon-source-linear" }
23+
trogon-source-notion = { path = "crates/trogon-source-notion" }
2324
trogon-source-slack = { path = "crates/trogon-source-slack" }
2425
trogon-source-telegram = { path = "crates/trogon-source-telegram" }
2526
trogon-std = { path = "crates/trogon-std" }

rsworkspace/crates/trogon-gateway/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ trogon-source-github = { workspace = true }
2626
trogon-source-gitlab = { workspace = true }
2727
trogon-source-incidentio = { workspace = true }
2828
trogon-source-linear = { workspace = true }
29+
trogon-source-notion = { workspace = true }
2930
trogon-source-slack = { workspace = true }
3031
trogon-source-telegram = { workspace = true }
3132
trogon-std = { workspace = true, features = ["clap"] }

rsworkspace/crates/trogon-gateway/src/config.rs

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ use trogon_source_gitlab::config::GitLabWebhookSecret;
1313
use trogon_source_incidentio::config::IncidentioConfig as IncidentioSourceConfig;
1414
use trogon_source_incidentio::incidentio_signing_secret::IncidentioSigningSecret;
1515
use trogon_source_linear::config::LinearWebhookSecret;
16+
use trogon_source_notion::NotionVerificationToken;
1617
use trogon_source_slack::config::SlackSigningSecret;
1718
use trogon_source_telegram::config::TelegramWebhookSecret;
1819
use trogon_std::{NonZeroDuration, ZeroDuration};
@@ -148,6 +149,8 @@ struct SourcesConfig {
148149
incidentio: IncidentioConfig,
149150
#[config(nested)]
150151
linear: LinearConfig,
152+
#[config(nested)]
153+
notion: NotionConfig,
151154
}
152155

153156
#[derive(Config)]
@@ -265,6 +268,20 @@ struct IncidentioConfig {
265268
timestamp_tolerance_secs: u64,
266269
}
267270

271+
#[derive(Config)]
272+
struct NotionConfig {
273+
#[config(env = "TROGON_SOURCE_NOTION_VERIFICATION_TOKEN")]
274+
verification_token: Option<String>,
275+
#[config(env = "TROGON_SOURCE_NOTION_SUBJECT_PREFIX", default = "notion")]
276+
subject_prefix: String,
277+
#[config(env = "TROGON_SOURCE_NOTION_STREAM_NAME", default = "NOTION")]
278+
stream_name: String,
279+
#[config(env = "TROGON_SOURCE_NOTION_STREAM_MAX_AGE_SECS", default = 604_800)]
280+
stream_max_age_secs: u64,
281+
#[config(env = "TROGON_SOURCE_NOTION_NATS_ACK_TIMEOUT_SECS", default = 10)]
282+
nats_ack_timeout_secs: u64,
283+
}
284+
268285
pub struct ResolvedHttpServerConfig {
269286
pub port: u16,
270287
}
@@ -279,6 +296,7 @@ pub struct ResolvedConfig {
279296
pub gitlab: Option<trogon_source_gitlab::GitlabConfig>,
280297
pub incidentio: Option<trogon_source_incidentio::IncidentioConfig>,
281298
pub linear: Option<trogon_source_linear::LinearConfig>,
299+
pub notion: Option<trogon_source_notion::NotionConfig>,
282300
}
283301

284302
impl ResolvedConfig {
@@ -290,6 +308,7 @@ impl ResolvedConfig {
290308
|| self.gitlab.is_some()
291309
|| self.incidentio.is_some()
292310
|| self.linear.is_some()
311+
|| self.notion.is_some()
293312
}
294313
}
295314

@@ -317,6 +336,7 @@ fn resolve(cfg: GatewayConfig, nats_overrides: &NatsArgs) -> Result<ResolvedConf
317336
let gitlab = resolve_gitlab(cfg.sources.gitlab, &mut errors);
318337
let incidentio = resolve_incidentio(cfg.sources.incidentio, &mut errors);
319338
let linear = resolve_linear(cfg.sources.linear, &mut errors);
339+
let notion = resolve_notion(cfg.sources.notion, &mut errors);
320340

321341
if !errors.is_empty() {
322342
return Err(ConfigError::Validation(errors));
@@ -334,6 +354,7 @@ fn resolve(cfg: GatewayConfig, nats_overrides: &NatsArgs) -> Result<ResolvedConf
334354
gitlab,
335355
incidentio,
336356
linear,
357+
notion,
337358
})
338359
}
339360

@@ -891,6 +912,86 @@ fn resolve_incidentio(
891912
})
892913
}
893914

915+
fn resolve_notion(
916+
section: NotionConfig,
917+
errors: &mut Vec<ConfigValidationError>,
918+
) -> Option<trogon_source_notion::NotionConfig> {
919+
let verification_token = match section.verification_token {
920+
Some(token) => token,
921+
None => {
922+
return None;
923+
}
924+
};
925+
926+
let verification_token = match NotionVerificationToken::new(verification_token) {
927+
Ok(token) => token,
928+
Err(err) => {
929+
errors.push(ConfigValidationError::invalid(
930+
"notion",
931+
"verification_token",
932+
err,
933+
));
934+
return None;
935+
}
936+
};
937+
938+
let subject_prefix = match NatsToken::new(section.subject_prefix) {
939+
Ok(token) => token,
940+
Err(err) => {
941+
errors.push(ConfigValidationError::invalid_subject_token(
942+
"notion",
943+
"subject_prefix",
944+
err,
945+
));
946+
return None;
947+
}
948+
};
949+
950+
let stream_name = match NatsToken::new(section.stream_name) {
951+
Ok(token) => token,
952+
Err(err) => {
953+
errors.push(ConfigValidationError::invalid_subject_token(
954+
"notion",
955+
"stream_name",
956+
err,
957+
));
958+
return None;
959+
}
960+
};
961+
962+
let nats_ack_timeout = match NonZeroDuration::from_secs(section.nats_ack_timeout_secs) {
963+
Ok(duration) => duration,
964+
Err(err) => {
965+
errors.push(ConfigValidationError::invalid(
966+
"notion",
967+
"nats_ack_timeout_secs",
968+
err,
969+
));
970+
return None;
971+
}
972+
};
973+
974+
let stream_max_age = match StreamMaxAge::from_secs(section.stream_max_age_secs) {
975+
Ok(age) => age,
976+
Err(err) => {
977+
errors.push(ConfigValidationError::invalid(
978+
"notion",
979+
"stream_max_age_secs",
980+
err,
981+
));
982+
return None;
983+
}
984+
};
985+
986+
Some(trogon_source_notion::NotionConfig {
987+
verification_token,
988+
subject_prefix,
989+
stream_name,
990+
stream_max_age,
991+
nats_ack_timeout,
992+
})
993+
}
994+
894995
#[cfg(test)]
895996
mod tests {
896997
use super::*;
@@ -976,6 +1077,15 @@ signing_secret = "{secret}"
9761077
)
9771078
}
9781079

1080+
fn notion_toml(token: &str) -> String {
1081+
format!(
1082+
r#"
1083+
[sources.notion]
1084+
verification_token = "{token}"
1085+
"#
1086+
)
1087+
}
1088+
9791089
fn incidentio_valid_test_secret() -> String {
9801090
["whsec_", "dGVzdC1zZWNyZXQ="].concat()
9811091
}
@@ -1134,6 +1244,23 @@ bot_token = ""
11341244
assert!(cfg.incidentio.is_some());
11351245
}
11361246

1247+
#[test]
1248+
fn notion_resolves_with_valid_token() {
1249+
let f = write_toml(&notion_toml("notion-verification-token-example"));
1250+
let cfg = load(Some(f.path())).expect("load failed");
1251+
assert!(cfg.notion.is_some());
1252+
}
1253+
1254+
#[test]
1255+
fn notion_missing_token_returns_none() {
1256+
let toml = r#"
1257+
[sources.notion]
1258+
"#;
1259+
let f = write_toml(toml);
1260+
let cfg = load(Some(f.path())).expect("load failed");
1261+
assert!(cfg.notion.is_none());
1262+
}
1263+
11371264
#[test]
11381265
fn linear_with_zero_timestamp_tolerance() {
11391266
let toml = r#"
@@ -1643,6 +1770,15 @@ port = 9090
16431770
);
16441771
}
16451772

1773+
#[test]
1774+
fn notion_empty_verification_token_is_invalid() {
1775+
let f = write_toml(&notion_toml(""));
1776+
let result = load(Some(f.path()));
1777+
assert!(
1778+
matches!(result, Err(ConfigError::Validation(ref errs)) if errs.iter().any(|e| e.contains("notion: invalid verification_token")))
1779+
);
1780+
}
1781+
16461782
#[test]
16471783
fn incidentio_invalid_secret_is_invalid() {
16481784
let f = write_toml(&incidentio_toml("whsec_not-base64!"));
@@ -1762,6 +1898,20 @@ subject_prefix = "has.dots"
17621898
);
17631899
}
17641900

1901+
#[test]
1902+
fn notion_invalid_subject_prefix() {
1903+
let toml = r#"
1904+
[sources.notion]
1905+
verification_token = "notion-verification-token-example"
1906+
subject_prefix = "has.dots"
1907+
"#;
1908+
let f = write_toml(toml);
1909+
let result = load(Some(f.path()));
1910+
assert!(
1911+
matches!(result, Err(ConfigError::Validation(ref errs)) if errs.iter().any(|e| e.contains("notion: invalid subject_prefix")))
1912+
);
1913+
}
1914+
17651915
#[test]
17661916
fn slack_invalid_stream_name() {
17671917
let toml = r#"
@@ -1835,6 +1985,20 @@ stream_name = "has.dots"
18351985
);
18361986
}
18371987

1988+
#[test]
1989+
fn notion_invalid_stream_name() {
1990+
let toml = r#"
1991+
[sources.notion]
1992+
verification_token = "notion-verification-token-example"
1993+
stream_name = "has.dots"
1994+
"#;
1995+
let f = write_toml(toml);
1996+
let result = load(Some(f.path()));
1997+
assert!(
1998+
matches!(result, Err(ConfigError::Validation(ref errs)) if errs.iter().any(|e| e.contains("notion: invalid stream_name")))
1999+
);
2000+
}
2001+
18382002
#[test]
18392003
fn incidentio_zero_nats_ack_timeout_is_error() {
18402004
let toml = format!(
@@ -1869,6 +2033,34 @@ stream_max_age_secs = 0
18692033
);
18702034
}
18712035

2036+
#[test]
2037+
fn notion_zero_nats_ack_timeout_is_error() {
2038+
let toml = r#"
2039+
[sources.notion]
2040+
verification_token = "notion-verification-token-example"
2041+
nats_ack_timeout_secs = 0
2042+
"#;
2043+
let f = write_toml(toml);
2044+
let result = load(Some(f.path()));
2045+
assert!(
2046+
matches!(result, Err(ConfigError::Validation(ref errs)) if errs.iter().any(|e| e.contains("notion: nats_ack_timeout_secs must not be zero")))
2047+
);
2048+
}
2049+
2050+
#[test]
2051+
fn notion_zero_stream_max_age_is_error() {
2052+
let toml = r#"
2053+
[sources.notion]
2054+
verification_token = "notion-verification-token-example"
2055+
stream_max_age_secs = 0
2056+
"#;
2057+
let f = write_toml(toml);
2058+
let result = load(Some(f.path()));
2059+
assert!(
2060+
matches!(result, Err(ConfigError::Validation(ref errs)) if errs.iter().any(|e| e.contains("notion: stream_max_age_secs must not be zero")))
2061+
);
2062+
}
2063+
18722064
#[test]
18732065
fn incidentio_zero_timestamp_tolerance_is_error() {
18742066
let toml = format!(

0 commit comments

Comments
 (0)