Skip to content

Commit 17a4b9a

Browse files
author
Robert
committed
Add SSE change notifications
1 parent 574d0dc commit 17a4b9a

8 files changed

Lines changed: 250 additions & 0 deletions

File tree

docs/src/SUMMARY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
- [Docker Images](./usage/docker-images.md)
77
- [Docker Compose](./usage/docker-compose.md)
88
- [Helm](./usage/helm.md)
9+
- [Change Notifications](./usage/change-notifications.md)
910
- [Integration](./integration.md)
1011
- [Pre-built Images](./integration/pre-built.md)
1112
- [Rust Crates](./integration/crates.md)
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Change Notifications
2+
3+
The HTTP server exposes `GET /v1/client/events` as a Server-Sent Events stream.
4+
Like other client endpoints, the request must include `X-Client-Id`.
5+
6+
This endpoint is disabled by default. Enable it with `--sync-events` or the
7+
`SYNC_EVENTS=true` environment variable.
8+
9+
When `AddVersion` accepts a new version for that client, the stream emits a
10+
`version` event:
11+
12+
```text
13+
event: version
14+
data: {"clientId":"...","versionId":"..."}
15+
```
16+
17+
This endpoint is only an invalidation signal. Clients should perform a normal
18+
TaskChampion sync after receiving an event.
19+
20+
## Simple Listener
21+
22+
This example runs a command for every received `version` event.
23+
24+
```bash
25+
#!/usr/bin/env bash
26+
set -euo pipefail
27+
28+
server_url="${TASKCHAMPION_SYNC_SERVER_URL:?set TASKCHAMPION_SYNC_SERVER_URL}"
29+
client_id="${TASKCHAMPION_SYNC_CLIENT_ID:?set TASKCHAMPION_SYNC_CLIENT_ID}"
30+
31+
curl -fsSN \
32+
-H "Accept: text/event-stream" \
33+
-H "X-Client-Id: ${client_id}" \
34+
"${server_url%/}/v1/client/events" |
35+
while IFS= read -r line; do
36+
case "${line}" in
37+
data:*)
38+
echo "TaskChampion changed: ${line#data: }"
39+
task sync
40+
;;
41+
esac
42+
done
43+
```

docs/src/usage/docker-images.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ empty to allow all clients.
2727
- `CREATE_CLIENTS` (default `true`) - if true, automatically create clients on
2828
first sync. If this is set to false, it is up to you to initialize clients in
2929
the DB.
30+
- `SYNC_EVENTS` (default `false`) - if true, enable the Server-Sent Events
31+
change notification endpoint at `/v1/client/events`.
3032

3133
### Example
3234

server/src/api/add_version.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ pub(crate) async fn service(
7474
rb.append_header((SNAPSHOT_REQUEST_HEADER, "urgency=high"));
7575
}
7676
};
77+
server_state.changes.notify(client_id, version_id);
7778
Ok(rb.finish())
7879
}
7980
Ok((AddVersionResult::ExpectedParentVersion(parent_version_id), _)) => {
@@ -106,6 +107,7 @@ mod test {
106107
web::{WebConfig, WebServer},
107108
};
108109
use actix_web::{http::StatusCode, test, App};
110+
use futures::StreamExt;
109111
use pretty_assertions::assert_eq;
110112
use taskchampion_sync_server_core::{InMemoryStorage, ServerConfig, Storage};
111113
use uuid::Uuid;
@@ -125,6 +127,7 @@ mod test {
125127
}
126128

127129
let server = WebServer::new(ServerConfig::default(), WebConfig::default(), storage);
130+
let mut changes = server.server_state.changes.subscribe(client_id);
128131
let app = App::new().configure(|sc| server.config(sc));
129132
let app = test::init_service(app).await;
130133

@@ -145,6 +148,11 @@ mod test {
145148
// the passed parent version ID, at least
146149
let new_version_id = resp.headers().get("X-Version-Id").unwrap();
147150
assert!(new_version_id != &version_id.to_string());
151+
let new_version_id = Uuid::parse_str(new_version_id.to_str().unwrap()).unwrap();
152+
153+
let event = changes.next().await.unwrap();
154+
assert_eq!(event.client_id, client_id);
155+
assert_eq!(event.version_id, new_version_id);
148156

149157
// Shapshot should be requested, since there is no existing snapshot
150158
let snapshot_request = resp.headers().get("X-Snapshot-Request").unwrap();

server/src/api/events.rs

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
use crate::api::{ServerState, CLIENT_ID_HEADER};
2+
use actix_web::{error, get, http::header, web, HttpRequest, HttpResponse, Result};
3+
use futures::{
4+
channel::mpsc::{unbounded, UnboundedReceiver, UnboundedSender},
5+
StreamExt,
6+
};
7+
use serde::Serialize;
8+
use std::{
9+
collections::HashMap,
10+
sync::{Arc, Mutex},
11+
};
12+
use taskchampion_sync_server_core::{ClientId, VersionId};
13+
14+
#[derive(Clone, Debug, Serialize)]
15+
#[serde(rename_all = "camelCase")]
16+
pub(crate) struct ChangeEvent {
17+
pub(crate) client_id: ClientId,
18+
pub(crate) version_id: VersionId,
19+
}
20+
21+
#[derive(Clone, Default)]
22+
pub(crate) struct ChangeNotifier {
23+
subscribers: Arc<Mutex<HashMap<ClientId, Vec<UnboundedSender<ChangeEvent>>>>>,
24+
}
25+
26+
impl ChangeNotifier {
27+
pub(crate) fn subscribe(&self, client_id: ClientId) -> UnboundedReceiver<ChangeEvent> {
28+
let (tx, rx) = unbounded();
29+
self.subscribers
30+
.lock()
31+
.expect("change notifier mutex poisoned")
32+
.entry(client_id)
33+
.or_default()
34+
.push(tx);
35+
rx
36+
}
37+
38+
pub(crate) fn notify(&self, client_id: ClientId, version_id: VersionId) {
39+
let event = ChangeEvent {
40+
client_id,
41+
version_id,
42+
};
43+
let mut subscribers = self
44+
.subscribers
45+
.lock()
46+
.expect("change notifier mutex poisoned");
47+
if let Some(client_subscribers) = subscribers.get_mut(&client_id) {
48+
client_subscribers
49+
.retain(|subscriber| subscriber.unbounded_send(event.clone()).is_ok());
50+
}
51+
}
52+
}
53+
54+
#[get("/v1/client/events")]
55+
pub(crate) async fn service(
56+
req: HttpRequest,
57+
server_state: web::Data<Arc<ServerState>>,
58+
) -> Result<HttpResponse> {
59+
if !server_state.web_config.sync_events {
60+
return Err(error::ErrorNotFound("sync events are not enabled"));
61+
}
62+
63+
let client_id = server_state.client_id_header(&req)?;
64+
let stream = server_state.changes.subscribe(client_id).map(|event| {
65+
let json = serde_json::to_string(&event).expect("change event serializes");
66+
Ok::<_, actix_web::Error>(web::Bytes::from(format!(
67+
"event: version\n\
68+
data: {json}\n\
69+
\n"
70+
)))
71+
});
72+
73+
Ok(HttpResponse::Ok()
74+
.append_header((header::CONTENT_TYPE, "text/event-stream"))
75+
.append_header((header::CACHE_CONTROL, "no-store, max-age=0"))
76+
.append_header((header::CONNECTION, "keep-alive"))
77+
.append_header((CLIENT_ID_HEADER, client_id.to_string()))
78+
.streaming(stream))
79+
}
80+
81+
#[cfg(test)]
82+
mod test {
83+
use super::*;
84+
use crate::web::{WebConfig, WebServer};
85+
use actix_web::{http::StatusCode, test, App};
86+
use taskchampion_sync_server_core::{InMemoryStorage, ServerConfig};
87+
use uuid::Uuid;
88+
89+
#[actix_rt::test]
90+
async fn notifier_delivers_events_for_matching_client() {
91+
let notifier = ChangeNotifier::default();
92+
let client_id = Uuid::new_v4();
93+
let version_id = Uuid::new_v4();
94+
let mut rx = notifier.subscribe(client_id);
95+
96+
notifier.notify(client_id, version_id);
97+
let event = rx.next().await.unwrap();
98+
assert_eq!(event.client_id, client_id);
99+
assert_eq!(event.version_id, version_id);
100+
}
101+
102+
#[actix_rt::test]
103+
async fn events_endpoint_uses_client_id_header() {
104+
let client_id = Uuid::new_v4();
105+
let server = WebServer::new(
106+
ServerConfig::default(),
107+
WebConfig {
108+
sync_events: true,
109+
..WebConfig::default()
110+
},
111+
InMemoryStorage::new(),
112+
);
113+
let app = App::new().configure(|sc| server.config(sc));
114+
let app = test::init_service(app).await;
115+
116+
let req = test::TestRequest::get()
117+
.uri("/v1/client/events")
118+
.append_header((CLIENT_ID_HEADER, client_id.to_string()))
119+
.to_request();
120+
let resp = test::call_service(&app, req).await;
121+
122+
assert_eq!(resp.status(), StatusCode::OK);
123+
assert_eq!(
124+
resp.headers().get(header::CONTENT_TYPE).unwrap(),
125+
"text/event-stream"
126+
);
127+
}
128+
129+
#[actix_rt::test]
130+
async fn events_endpoint_is_disabled_by_default() {
131+
let client_id = Uuid::new_v4();
132+
let server = WebServer::new(
133+
ServerConfig::default(),
134+
WebConfig::default(),
135+
InMemoryStorage::new(),
136+
);
137+
let app = App::new().configure(|sc| server.config(sc));
138+
let app = test::init_service(app).await;
139+
140+
let req = test::TestRequest::get()
141+
.uri("/v1/client/events")
142+
.append_header((CLIENT_ID_HEADER, client_id.to_string()))
143+
.to_request();
144+
let resp = test::call_service(&app, req).await;
145+
146+
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
147+
}
148+
}

server/src/api/mod.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,12 @@ use crate::web::WebConfig;
55

66
mod add_snapshot;
77
mod add_version;
8+
mod events;
89
mod get_child_version;
910
mod get_snapshot;
1011

12+
pub(crate) use events::ChangeNotifier;
13+
1114
/// The content-type for history segments (opaque blobs of bytes)
1215
pub(crate) const HISTORY_SEGMENT_CONTENT_TYPE: &str =
1316
"application/vnd.taskchampion.history-segment";
@@ -31,6 +34,7 @@ pub(crate) const SNAPSHOT_REQUEST_HEADER: &str = "X-Snapshot-Request";
3134
pub(crate) struct ServerState {
3235
pub(crate) server: Server,
3336
pub(crate) web_config: WebConfig,
37+
pub(crate) changes: ChangeNotifier,
3438
}
3539

3640
impl ServerState {
@@ -60,6 +64,7 @@ pub(crate) fn api_scope() -> Scope {
6064
.service(add_version::service)
6165
.service(get_snapshot::service)
6266
.service(add_snapshot::service)
67+
.service(events::service)
6368
}
6469

6570
/// Convert a `anyhow::Error` to an Actix ISE
@@ -89,8 +94,10 @@ mod test {
8994
web_config: WebConfig {
9095
client_id_allowlist: None,
9196
create_clients: true,
97+
sync_events: false,
9298
..WebConfig::default()
9399
},
100+
changes: ChangeNotifier::default(),
94101
};
95102
let req = actix_web::test::TestRequest::default()
96103
.insert_header((CLIENT_ID_HEADER, client_id.to_string()))
@@ -107,8 +114,10 @@ mod test {
107114
web_config: WebConfig {
108115
client_id_allowlist: Some([client_id_ok].into()),
109116
create_clients: true,
117+
sync_events: false,
110118
..WebConfig::default()
111119
},
120+
changes: ChangeNotifier::default(),
112121
};
113122
let req = actix_web::test::TestRequest::default()
114123
.insert_header((CLIENT_ID_HEADER, client_id_ok.to_string()))

server/src/args.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,13 @@ pub fn command() -> Command {
3434
.action(ArgAction::SetFalse)
3535
.required(false),
3636
)
37+
.arg(
38+
arg!(--"sync-events" "Enable Server-Sent Events change notifications at /v1/client/events")
39+
.env("SYNC_EVENTS")
40+
.default_value("false")
41+
.action(ArgAction::SetTrue)
42+
.required(false),
43+
)
3744
.arg(
3845
arg!(--"snapshot-versions" <NUM> "Target number of versions between snapshots")
3946
.value_parser(value_parser!(u32))
@@ -63,6 +70,7 @@ pub fn web_config_from_matches(matches: &ArgMatches) -> WebConfig {
6370
.get_many("allow-client-id")
6471
.map(|ids| ids.copied().collect()),
6572
create_clients: matches.get_one("create-clients").copied().unwrap_or(true),
73+
sync_events: matches.get_one("sync-events").copied().unwrap_or(false),
6674
listen_addresses: matches
6775
.get_many::<String>("listen")
6876
.unwrap()
@@ -282,6 +290,34 @@ mod test {
282290
});
283291
}
284292

293+
#[test]
294+
fn command_sync_events_default() {
295+
with_var_unset("SYNC_EVENTS", || {
296+
let matches = command().get_matches_from(["tss", "--listen", "localhost:8080"]);
297+
let web_config = web_config_from_matches(&matches);
298+
assert_eq!(web_config.sync_events, false);
299+
});
300+
}
301+
302+
#[test]
303+
fn command_sync_events_cmdline() {
304+
with_var_unset("SYNC_EVENTS", || {
305+
let matches =
306+
command().get_matches_from(["tss", "--listen", "localhost:8080", "--sync-events"]);
307+
let web_config = web_config_from_matches(&matches);
308+
assert_eq!(web_config.sync_events, true);
309+
});
310+
}
311+
312+
#[test]
313+
fn command_sync_events_env_true() {
314+
with_var("SYNC_EVENTS", Some("true"), || {
315+
let matches = command().get_matches_from(["tss", "--listen", "localhost:8080"]);
316+
let web_config = web_config_from_matches(&matches);
317+
assert_eq!(web_config.sync_events, true);
318+
});
319+
}
320+
285321
#[actix_rt::test]
286322
async fn test_index_get() {
287323
let server = WebServer::new(

server/src/web.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ fn print_error<B>(res: ServiceResponse<B>) -> actix_web::Result<ErrorHandlerResp
2222
pub struct WebConfig {
2323
pub client_id_allowlist: Option<HashSet<Uuid>>,
2424
pub create_clients: bool,
25+
pub sync_events: bool,
2526
pub listen_addresses: Vec<String>,
2627
}
2728

@@ -30,6 +31,7 @@ impl Default for WebConfig {
3031
Self {
3132
client_id_allowlist: Default::default(),
3233
create_clients: true,
34+
sync_events: false,
3335
listen_addresses: vec![],
3436
}
3537
}
@@ -57,6 +59,7 @@ impl WebServer {
5759
server_state: Arc::new(ServerState {
5860
server: Server::new(config, storage),
5961
web_config,
62+
changes: Default::default(),
6063
}),
6164
}
6265
}

0 commit comments

Comments
 (0)