1use aion_proto::{
10 ClusterSubscription, FilteredSubscription, FirehoseSubscription, PerWorkflowSubscription,
11 ProtoActivityId, ProtoWorkflowId, SubscriptionRequest, TranscriptSubscription, WireError,
12 subscription_request,
13};
14use axum::extract::ws::{Message, WebSocket};
15use serde_json::{Map, Value};
16
17use crate::error::ServerError;
18
19pub async fn read_subscription_request(
38 socket: &mut WebSocket,
39) -> Result<Option<SubscriptionRequest>, ServerError> {
40 loop {
41 let Some(message) = socket.recv().await else {
43 return Ok(None);
44 };
45 let message = message.map_err(|source| {
46 WireError::invalid_input(format!(
47 "failed to read websocket subscription request: {source}"
48 ))
49 })?;
50
51 match message {
52 Message::Text(text) => return decode_subscription_request(text.as_bytes()).map(Some),
53 Message::Binary(bytes) => return decode_subscription_request(&bytes).map(Some),
54 Message::Ping(_) | Message::Pong(_) => {}
55 Message::Close(_) => return Ok(None),
57 }
58 }
59}
60
61pub fn decode_subscription_request(bytes: &[u8]) -> Result<SubscriptionRequest, ServerError> {
68 let value = serde_json::from_slice::<Value>(bytes).map_err(|source| {
69 WireError::invalid_input(format!("invalid websocket subscription JSON: {source}"))
70 })?;
71 decode_subscription_value(&value)
72}
73
74fn decode_subscription_value(value: &Value) -> Result<SubscriptionRequest, ServerError> {
75 if let Ok(request) = serde_json::from_value::<SubscriptionRequest>(value.clone())
76 && request.subscription.is_some()
77 {
78 return Ok(request);
79 }
80
81 let subscription = value.get("subscription").unwrap_or(value);
82 let Some(subscription) = subscription.as_object() else {
83 return Err(
84 WireError::invalid_input("websocket subscription must be a JSON object").into(),
85 );
86 };
87
88 if let Some(value) = subscription.get("per_workflow") {
89 return Ok(SubscriptionRequest {
90 subscription: Some(subscription_request::Subscription::PerWorkflow(
91 decode_per_workflow_subscription(value)?,
92 )),
93 });
94 }
95 if let Some(value) = subscription.get("filtered") {
96 return Ok(SubscriptionRequest {
97 subscription: Some(subscription_request::Subscription::Filtered(
98 decode_filtered_subscription(value)?,
99 )),
100 });
101 }
102 if let Some(value) = subscription.get("firehose") {
103 return Ok(SubscriptionRequest {
104 subscription: Some(subscription_request::Subscription::Firehose(
105 decode_firehose_subscription(value)?,
106 )),
107 });
108 }
109 if let Some(value) = subscription.get("cluster") {
110 return Ok(SubscriptionRequest {
111 subscription: Some(subscription_request::Subscription::Cluster(
112 decode_cluster_subscription(value)?,
113 )),
114 });
115 }
116 if let Some(value) = subscription.get("transcript") {
117 return Ok(SubscriptionRequest {
118 subscription: Some(subscription_request::Subscription::Transcript(
119 decode_transcript_subscription(value)?,
120 )),
121 });
122 }
123
124 Err(WireError::invalid_input(
125 "websocket subscription must contain per_workflow, filtered, firehose, cluster, or transcript",
126 )
127 .into())
128}
129
130fn decode_per_workflow_subscription(value: &Value) -> Result<PerWorkflowSubscription, ServerError> {
131 let object = subscription_object(value, "per-workflow")?;
132 Ok(PerWorkflowSubscription {
133 namespace: required_string(object, "namespace", "per-workflow subscription")?.to_owned(),
134 workflow_id: Some(decode_workflow_id_value(
135 object.get("workflow_id").ok_or_else(|| {
136 WireError::invalid_input("per-workflow subscription requires workflow_id")
137 })?,
138 )?),
139 resume_from_seq: decode_resume_from_seq(object)?,
140 })
141}
142
143fn decode_resume_from_seq(object: &Map<String, Value>) -> Result<Option<u64>, ServerError> {
147 match object.get("resume_from_seq") {
148 None | Some(Value::Null) => Ok(None),
149 Some(value) => value.as_u64().map(Some).ok_or_else(|| {
150 WireError::invalid_input(
151 "per-workflow subscription resume_from_seq must be an unsigned integer",
152 )
153 .into()
154 }),
155 }
156}
157
158fn decode_filtered_subscription(value: &Value) -> Result<FilteredSubscription, ServerError> {
159 let object = subscription_object(value, "filtered")?;
160 let status = match object.get("status") {
161 Some(Value::String(status)) => Some(decode_status_name(status)?),
162 Some(Value::Number(status)) => status.as_i64().and_then(|value| i32::try_from(value).ok()),
163 Some(Value::Null) | None => None,
164 Some(_other) => None,
165 };
166 Ok(FilteredSubscription {
167 namespace: required_string(object, "namespace", "filtered subscription")?.to_owned(),
168 workflow_type: object
169 .get("workflow_type")
170 .and_then(Value::as_str)
171 .map(str::to_owned),
172 status,
173 namespace_selector: object
174 .get("namespace_selector")
175 .and_then(Value::as_str)
176 .map(str::to_owned),
177 })
178}
179
180fn decode_cluster_subscription(value: &Value) -> Result<ClusterSubscription, ServerError> {
185 let after_seq = match value.as_object().and_then(|object| object.get("after_seq")) {
188 None | Some(Value::Null) => 0,
189 Some(seq) => seq.as_u64().ok_or_else(|| {
190 WireError::invalid_input("cluster subscription after_seq must be an unsigned integer")
191 })?,
192 };
193 Ok(ClusterSubscription { after_seq })
194}
195
196fn decode_transcript_subscription(value: &Value) -> Result<TranscriptSubscription, ServerError> {
207 let object = subscription_object(value, "transcript")?;
208 let workflow_id = decode_workflow_id_value(object.get("workflow_id").ok_or_else(|| {
209 WireError::invalid_input("transcript subscription requires workflow_id")
210 })?)?;
211 let run_id = decode_run_id_value(
212 object
213 .get("run_id")
214 .ok_or_else(|| WireError::invalid_input("transcript subscription requires run_id"))?,
215 )?;
216 let activity_id = decode_activity_id_value(object.get("activity_id").ok_or_else(|| {
217 WireError::invalid_input("transcript subscription requires activity_id")
218 })?)?;
219 let attempt = decode_attempt(object)?;
220 Ok(TranscriptSubscription {
221 namespace: required_string(object, "namespace", "transcript subscription")?.to_owned(),
222 workflow_id: Some(workflow_id),
223 run_id: Some(run_id),
224 activity_id: Some(activity_id),
225 attempt,
226 after_seq: decode_after_seq(object)?,
227 })
228}
229
230fn decode_run_id_value(value: &Value) -> Result<aion_proto::ProtoRunId, ServerError> {
233 if let Some(uuid) = value.as_str() {
234 return Ok(aion_proto::ProtoRunId {
235 uuid: uuid.to_owned(),
236 });
237 }
238 serde_json::from_value::<aion_proto::ProtoRunId>(value.clone()).map_err(|source| {
239 WireError::invalid_input(format!("invalid transcript subscription run_id: {source}")).into()
240 })
241}
242
243fn decode_activity_id_value(value: &Value) -> Result<ProtoActivityId, ServerError> {
246 if let Some(sequence_position) = value.as_u64() {
247 return Ok(ProtoActivityId { sequence_position });
248 }
249 serde_json::from_value::<ProtoActivityId>(value.clone()).map_err(|source| {
250 WireError::invalid_input(format!(
251 "invalid transcript subscription activity_id: {source}"
252 ))
253 .into()
254 })
255}
256
257fn decode_attempt(object: &Map<String, Value>) -> Result<u32, ServerError> {
259 match object.get("attempt") {
260 None | Some(Value::Null) => Ok(0),
263 Some(value) => value
264 .as_u64()
265 .and_then(|attempt| u32::try_from(attempt).ok())
266 .ok_or_else(|| {
267 WireError::invalid_input(
268 "transcript subscription attempt must be an unsigned 32-bit integer",
269 )
270 .into()
271 }),
272 }
273}
274
275fn decode_after_seq(object: &Map<String, Value>) -> Result<Option<u64>, ServerError> {
277 match object.get("after_seq") {
278 None | Some(Value::Null) => Ok(None),
279 Some(value) => value.as_u64().map(Some).ok_or_else(|| {
280 WireError::invalid_input(
281 "transcript subscription after_seq must be an unsigned integer",
282 )
283 .into()
284 }),
285 }
286}
287
288fn decode_firehose_subscription(value: &Value) -> Result<FirehoseSubscription, ServerError> {
289 let object = subscription_object(value, "firehose")?;
290 let namespace = object
291 .get("namespace")
292 .or_else(|| object.get("namespace_selector"))
293 .and_then(Value::as_str)
294 .ok_or_else(|| WireError::invalid_input("firehose subscription requires namespace"))?;
295 Ok(FirehoseSubscription {
296 namespace: namespace.to_owned(),
297 })
298}
299
300fn subscription_object<'a>(
301 value: &'a Value,
302 subscription_name: &str,
303) -> Result<&'a Map<String, Value>, ServerError> {
304 value.as_object().ok_or_else(|| {
305 WireError::invalid_input(format!(
306 "{subscription_name} subscription must be a JSON object"
307 ))
308 .into()
309 })
310}
311
312fn required_string<'a>(
313 object: &'a Map<String, Value>,
314 key: &str,
315 context: &str,
316) -> Result<&'a str, ServerError> {
317 object
318 .get(key)
319 .and_then(Value::as_str)
320 .ok_or_else(|| WireError::invalid_input(format!("{context} requires {key}")).into())
321}
322
323fn decode_workflow_id_value(value: &Value) -> Result<ProtoWorkflowId, ServerError> {
324 if let Some(uuid) = value.as_str() {
325 return Ok(ProtoWorkflowId {
326 uuid: uuid.to_owned(),
327 });
328 }
329 serde_json::from_value::<ProtoWorkflowId>(value.clone()).map_err(|source| {
330 WireError::invalid_input(format!(
331 "invalid per-workflow subscription workflow_id: {source}"
332 ))
333 .into()
334 })
335}
336
337fn decode_status_name(status: &str) -> Result<i32, ServerError> {
338 match status {
339 "running" | "Running" => Ok(aion_proto::ProtoWorkflowStatus::Running as i32),
340 "completed" | "Completed" => Ok(aion_proto::ProtoWorkflowStatus::Completed as i32),
341 "failed" | "Failed" => Ok(aion_proto::ProtoWorkflowStatus::Failed as i32),
342 "cancelled" | "Cancelled" | "canceled" | "Canceled" => {
343 Ok(aion_proto::ProtoWorkflowStatus::Cancelled as i32)
344 }
345 "timed_out" | "TimedOut" => Ok(aion_proto::ProtoWorkflowStatus::TimedOut as i32),
346 "continued_as_new" | "ContinuedAsNew" => {
347 Ok(aion_proto::ProtoWorkflowStatus::ContinuedAsNew as i32)
348 }
349 "paused" | "Paused" => Ok(aion_proto::ProtoWorkflowStatus::Paused as i32),
350 other => Err(WireError::invalid_input(format!(
351 "invalid workflow status in websocket subscription: {other}"
352 ))
353 .into()),
354 }
355}
356
357#[cfg(test)]
358mod tests {
359 use aion_proto::{WireErrorCode, subscription_request};
360 use serde_json::json;
361
362 use super::decode_subscription_request;
363 use crate::error::ServerError;
364
365 fn decode(value: &serde_json::Value) -> Result<aion_proto::SubscriptionRequest, ServerError> {
366 decode_subscription_request(value.to_string().as_bytes())
367 }
368
369 fn per_workflow(
370 request: aion_proto::SubscriptionRequest,
371 ) -> Result<aion_proto::PerWorkflowSubscription, Box<dyn std::error::Error>> {
372 match request.subscription {
373 Some(subscription_request::Subscription::PerWorkflow(subscription)) => Ok(subscription),
374 other => Err(format!("expected a per-workflow subscription, got {other:?}").into()),
375 }
376 }
377
378 #[test]
379 fn per_workflow_resume_from_seq_is_decoded() -> Result<(), Box<dyn std::error::Error>> {
380 let workflow_id = uuid::Uuid::from_u128(7).to_string();
381 let request = decode(&json!({
382 "per_workflow": {
383 "namespace": "tenant-a",
384 "workflow_id": workflow_id,
385 "resume_from_seq": 42,
386 }
387 }))?;
388
389 assert_eq!(per_workflow(request)?.resume_from_seq, Some(42));
390 Ok(())
391 }
392
393 #[test]
394 fn per_workflow_resume_from_seq_zero_passes_decode_for_post_guard_validation()
395 -> Result<(), Box<dyn std::error::Error>> {
396 let request = decode(&json!({
399 "per_workflow": {
400 "namespace": "tenant-a",
401 "workflow_id": uuid::Uuid::from_u128(7).to_string(),
402 "resume_from_seq": 0,
403 }
404 }))?;
405
406 assert_eq!(per_workflow(request)?.resume_from_seq, Some(0));
407 Ok(())
408 }
409
410 #[test]
411 fn per_workflow_resume_from_seq_absent_or_null_is_none()
412 -> Result<(), Box<dyn std::error::Error>> {
413 let workflow_id = uuid::Uuid::from_u128(7).to_string();
414 let absent = decode(&json!({
415 "per_workflow": { "namespace": "tenant-a", "workflow_id": workflow_id }
416 }))?;
417 let null = decode(&json!({
418 "per_workflow": {
419 "namespace": "tenant-a",
420 "workflow_id": workflow_id,
421 "resume_from_seq": null,
422 }
423 }))?;
424
425 assert_eq!(per_workflow(absent)?.resume_from_seq, None);
426 assert_eq!(per_workflow(null)?.resume_from_seq, None);
427 Ok(())
428 }
429
430 #[test]
431 fn per_workflow_resume_from_seq_rejects_non_unsigned_values() {
432 for bad in [json!(-1), json!(1.5), json!("7")] {
433 let result = decode(&json!({
434 "per_workflow": {
435 "namespace": "tenant-a",
436 "workflow_id": uuid::Uuid::from_u128(7).to_string(),
437 "resume_from_seq": bad,
438 }
439 }));
440 let error = result.err().map(|error| error.to_wire_error());
441 assert_eq!(
442 error.as_ref().map(|error| error.code),
443 Some(WireErrorCode::InvalidInput),
444 "expected invalid_input, got {error:?}"
445 );
446 }
447 }
448
449 #[test]
450 fn wrapped_subscription_shape_is_accepted() -> Result<(), Box<dyn std::error::Error>> {
451 let request = decode(&json!({
452 "subscription": {
453 "per_workflow": {
454 "namespace": "tenant-a",
455 "workflow_id": { "uuid": uuid::Uuid::from_u128(7).to_string() },
456 "resume_from_seq": 3,
457 }
458 }
459 }))?;
460
461 let subscription = per_workflow(request)?;
462 assert_eq!(subscription.namespace, "tenant-a");
463 assert_eq!(subscription.resume_from_seq, Some(3));
464 Ok(())
465 }
466
467 #[test]
468 fn filtered_and_firehose_shapes_still_decode() -> Result<(), Box<dyn std::error::Error>> {
469 let filtered = decode(&json!({
470 "filtered": { "namespace": "tenant-a", "status": "Completed" }
471 }))?;
472 let firehose = decode(&json!({ "firehose": { "namespace": "tenant-a" } }))?;
473
474 assert!(matches!(
475 filtered.subscription,
476 Some(subscription_request::Subscription::Filtered(_))
477 ));
478 assert!(matches!(
479 firehose.subscription,
480 Some(subscription_request::Subscription::Firehose(_))
481 ));
482 Ok(())
483 }
484
485 #[test]
490 fn transcript_subscription_decodes_axes_and_cursor() -> Result<(), Box<dyn std::error::Error>> {
491 let workflow_id = uuid::Uuid::from_u128(7).to_string();
492 let run_id = uuid::Uuid::from_u128(0x11).to_string();
493 let request = decode(&json!({
494 "transcript": {
495 "namespace": "tenant-a",
496 "workflow_id": workflow_id,
497 "run_id": run_id,
498 "activity_id": 3,
499 "attempt": 2,
500 "after_seq": 5,
501 }
502 }))?;
503 let Some(subscription_request::Subscription::Transcript(transcript)) = request.subscription
504 else {
505 return Err("expected a transcript subscription".into());
506 };
507 assert_eq!(transcript.namespace, "tenant-a");
508 assert_eq!(transcript.run_id.map(|id| id.uuid), Some(run_id));
509 assert_eq!(
510 transcript.activity_id.map(|id| id.sequence_position),
511 Some(3)
512 );
513 assert_eq!(transcript.attempt, 2);
514 assert_eq!(transcript.after_seq, Some(5));
515 Ok(())
516 }
517
518 #[test]
523 fn transcript_subscription_requires_run_id() {
524 let error = decode(&json!({
525 "transcript": {
526 "namespace": "tenant-a",
527 "workflow_id": uuid::Uuid::from_u128(7).to_string(),
528 "activity_id": 3,
529 "attempt": 2,
530 }
531 }))
532 .err()
533 .map(|error| error.to_wire_error());
534 assert_eq!(
535 error.map(|error| error.code),
536 Some(WireErrorCode::InvalidInput)
537 );
538 }
539
540 #[test]
541 fn transcript_subscription_defaults_attempt_and_cursor()
542 -> Result<(), Box<dyn std::error::Error>> {
543 let request = decode(&json!({
544 "transcript": {
545 "namespace": "tenant-a",
546 "workflow_id": uuid::Uuid::from_u128(7).to_string(),
547 "run_id": uuid::Uuid::from_u128(0x11).to_string(),
548 "activity_id": 3,
549 }
550 }))?;
551 let Some(subscription_request::Subscription::Transcript(transcript)) = request.subscription
552 else {
553 return Err("expected a transcript subscription".into());
554 };
555 assert_eq!(transcript.attempt, 0, "absent attempt defaults to 0");
556 assert_eq!(
557 transcript.after_seq, None,
558 "absent after_seq is a fresh subscriber"
559 );
560 Ok(())
561 }
562
563 #[test]
564 fn transcript_subscription_requires_activity_id() {
565 let error = decode(&json!({
566 "transcript": {
567 "namespace": "tenant-a",
568 "workflow_id": uuid::Uuid::from_u128(7).to_string(),
569 "run_id": uuid::Uuid::from_u128(0x11).to_string(),
570 }
571 }))
572 .err()
573 .map(|error| error.to_wire_error());
574 assert_eq!(
575 error.map(|error| error.code),
576 Some(WireErrorCode::InvalidInput)
577 );
578 }
579
580 #[test]
581 fn unknown_subscription_shape_is_invalid_input() {
582 let error = decode(&json!({ "mystery": {} }))
583 .err()
584 .map(|error| error.to_wire_error());
585 assert_eq!(
586 error.map(|error| error.code),
587 Some(WireErrorCode::InvalidInput)
588 );
589 }
590}