Skip to main content

aion_server/api/
ws_subscription.rs

1//! WebSocket subscription request reading and JSON decoding.
2//!
3//! The first client frame on `/events/stream` is a JSON `SubscriptionRequest`.
4//! This module owns the tolerant decode of that frame: the canonical proto
5//! serde shape is accepted first, then the documented hand-written shapes
6//! (`per_workflow` / `filtered` / `firehose`, optionally wrapped in
7//! `{"subscription": ...}`).
8
9use aion_proto::{
10    FilteredSubscription, FirehoseSubscription, PerWorkflowSubscription, ProtoWorkflowId,
11    SubscriptionRequest, WireError, subscription_request,
12};
13use axum::extract::ws::{Message, WebSocket};
14use serde_json::{Map, Value};
15
16use crate::error::ServerError;
17
18/// Read the first subscription frame from an accepted WebSocket.
19///
20/// Ping/pong frames are ignored while waiting; a close, socket error, or
21/// malformed frame is a decode failure the caller reports as one terminal
22/// error frame.
23///
24/// # Errors
25///
26/// Returns [`ServerError::Wire`] (`invalid_input`) when the socket closes
27/// before a request arrives or the request cannot be decoded.
28pub async fn read_subscription_request(
29    socket: &mut WebSocket,
30) -> Result<SubscriptionRequest, ServerError> {
31    loop {
32        let Some(message) = socket.recv().await else {
33            return Err(
34                WireError::invalid_input("websocket subscription request is missing").into(),
35            );
36        };
37        let message = message.map_err(|source| {
38            WireError::invalid_input(format!(
39                "failed to read websocket subscription request: {source}"
40            ))
41        })?;
42
43        match message {
44            Message::Text(text) => return decode_subscription_request(text.as_bytes()),
45            Message::Binary(bytes) => return decode_subscription_request(&bytes),
46            Message::Ping(_) | Message::Pong(_) => {}
47            Message::Close(_) => {
48                return Err(WireError::invalid_input(
49                    "websocket closed before subscription request",
50                )
51                .into());
52            }
53        }
54    }
55}
56
57/// Decode a subscription request from raw frame bytes.
58///
59/// # Errors
60///
61/// Returns [`ServerError::Wire`] (`invalid_input`) when the bytes are not a
62/// recognizable subscription JSON object.
63pub fn decode_subscription_request(bytes: &[u8]) -> Result<SubscriptionRequest, ServerError> {
64    let value = serde_json::from_slice::<Value>(bytes).map_err(|source| {
65        WireError::invalid_input(format!("invalid websocket subscription JSON: {source}"))
66    })?;
67    decode_subscription_value(&value)
68}
69
70fn decode_subscription_value(value: &Value) -> Result<SubscriptionRequest, ServerError> {
71    if let Ok(request) = serde_json::from_value::<SubscriptionRequest>(value.clone()) {
72        if request.subscription.is_some() {
73            return Ok(request);
74        }
75    }
76
77    let subscription = value.get("subscription").unwrap_or(value);
78    let Some(subscription) = subscription.as_object() else {
79        return Err(
80            WireError::invalid_input("websocket subscription must be a JSON object").into(),
81        );
82    };
83
84    if let Some(value) = subscription.get("per_workflow") {
85        return Ok(SubscriptionRequest {
86            subscription: Some(subscription_request::Subscription::PerWorkflow(
87                decode_per_workflow_subscription(value)?,
88            )),
89        });
90    }
91    if let Some(value) = subscription.get("filtered") {
92        return Ok(SubscriptionRequest {
93            subscription: Some(subscription_request::Subscription::Filtered(
94                decode_filtered_subscription(value)?,
95            )),
96        });
97    }
98    if let Some(value) = subscription.get("firehose") {
99        return Ok(SubscriptionRequest {
100            subscription: Some(subscription_request::Subscription::Firehose(
101                decode_firehose_subscription(value)?,
102            )),
103        });
104    }
105
106    Err(WireError::invalid_input(
107        "websocket subscription must contain per_workflow, filtered, or firehose",
108    )
109    .into())
110}
111
112fn decode_per_workflow_subscription(value: &Value) -> Result<PerWorkflowSubscription, ServerError> {
113    let object = subscription_object(value, "per-workflow")?;
114    Ok(PerWorkflowSubscription {
115        namespace: required_string(object, "namespace", "per-workflow subscription")?.to_owned(),
116        workflow_id: Some(decode_workflow_id_value(
117            object.get("workflow_id").ok_or_else(|| {
118                WireError::invalid_input("per-workflow subscription requires workflow_id")
119            })?,
120        )?),
121        resume_from_seq: decode_resume_from_seq(object)?,
122    })
123}
124
125/// Decode the optional resume cursor. Presence only: range validation against
126/// the recorded history head happens after the namespace guard verdict, in
127/// `stream::resume`, so decoding can never leak existence information.
128fn decode_resume_from_seq(object: &Map<String, Value>) -> Result<Option<u64>, ServerError> {
129    match object.get("resume_from_seq") {
130        None | Some(Value::Null) => Ok(None),
131        Some(value) => value.as_u64().map(Some).ok_or_else(|| {
132            WireError::invalid_input(
133                "per-workflow subscription resume_from_seq must be an unsigned integer",
134            )
135            .into()
136        }),
137    }
138}
139
140fn decode_filtered_subscription(value: &Value) -> Result<FilteredSubscription, ServerError> {
141    let object = subscription_object(value, "filtered")?;
142    let status = match object.get("status") {
143        Some(Value::String(status)) => Some(decode_status_name(status)?),
144        Some(Value::Number(status)) => status.as_i64().and_then(|value| i32::try_from(value).ok()),
145        Some(Value::Null) | None => None,
146        Some(_other) => None,
147    };
148    Ok(FilteredSubscription {
149        namespace: required_string(object, "namespace", "filtered subscription")?.to_owned(),
150        workflow_type: object
151            .get("workflow_type")
152            .and_then(Value::as_str)
153            .map(str::to_owned),
154        status,
155        namespace_selector: object
156            .get("namespace_selector")
157            .and_then(Value::as_str)
158            .map(str::to_owned),
159    })
160}
161
162fn decode_firehose_subscription(value: &Value) -> Result<FirehoseSubscription, ServerError> {
163    let object = subscription_object(value, "firehose")?;
164    let namespace = object
165        .get("namespace")
166        .or_else(|| object.get("namespace_selector"))
167        .and_then(Value::as_str)
168        .ok_or_else(|| WireError::invalid_input("firehose subscription requires namespace"))?;
169    Ok(FirehoseSubscription {
170        namespace: namespace.to_owned(),
171    })
172}
173
174fn subscription_object<'a>(
175    value: &'a Value,
176    subscription_name: &str,
177) -> Result<&'a Map<String, Value>, ServerError> {
178    value.as_object().ok_or_else(|| {
179        WireError::invalid_input(format!(
180            "{subscription_name} subscription must be a JSON object"
181        ))
182        .into()
183    })
184}
185
186fn required_string<'a>(
187    object: &'a Map<String, Value>,
188    key: &str,
189    context: &str,
190) -> Result<&'a str, ServerError> {
191    object
192        .get(key)
193        .and_then(Value::as_str)
194        .ok_or_else(|| WireError::invalid_input(format!("{context} requires {key}")).into())
195}
196
197fn decode_workflow_id_value(value: &Value) -> Result<ProtoWorkflowId, ServerError> {
198    if let Some(uuid) = value.as_str() {
199        return Ok(ProtoWorkflowId {
200            uuid: uuid.to_owned(),
201        });
202    }
203    serde_json::from_value::<ProtoWorkflowId>(value.clone()).map_err(|source| {
204        WireError::invalid_input(format!(
205            "invalid per-workflow subscription workflow_id: {source}"
206        ))
207        .into()
208    })
209}
210
211fn decode_status_name(status: &str) -> Result<i32, ServerError> {
212    match status {
213        "running" | "Running" => Ok(aion_proto::ProtoWorkflowStatus::Running as i32),
214        "completed" | "Completed" => Ok(aion_proto::ProtoWorkflowStatus::Completed as i32),
215        "failed" | "Failed" => Ok(aion_proto::ProtoWorkflowStatus::Failed as i32),
216        "cancelled" | "Cancelled" | "canceled" | "Canceled" => {
217            Ok(aion_proto::ProtoWorkflowStatus::Cancelled as i32)
218        }
219        "timed_out" | "TimedOut" => Ok(aion_proto::ProtoWorkflowStatus::TimedOut as i32),
220        "continued_as_new" | "ContinuedAsNew" => {
221            Ok(aion_proto::ProtoWorkflowStatus::ContinuedAsNew as i32)
222        }
223        other => Err(WireError::invalid_input(format!(
224            "invalid workflow status in websocket subscription: {other}"
225        ))
226        .into()),
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use aion_proto::{WireErrorCode, subscription_request};
233    use serde_json::json;
234
235    use super::decode_subscription_request;
236    use crate::error::ServerError;
237
238    fn decode(value: &serde_json::Value) -> Result<aion_proto::SubscriptionRequest, ServerError> {
239        decode_subscription_request(value.to_string().as_bytes())
240    }
241
242    fn per_workflow(
243        request: aion_proto::SubscriptionRequest,
244    ) -> Result<aion_proto::PerWorkflowSubscription, Box<dyn std::error::Error>> {
245        match request.subscription {
246            Some(subscription_request::Subscription::PerWorkflow(subscription)) => Ok(subscription),
247            other => Err(format!("expected a per-workflow subscription, got {other:?}").into()),
248        }
249    }
250
251    #[test]
252    fn per_workflow_resume_from_seq_is_decoded() -> Result<(), Box<dyn std::error::Error>> {
253        let workflow_id = uuid::Uuid::from_u128(7).to_string();
254        let request = decode(&json!({
255            "per_workflow": {
256                "namespace": "tenant-a",
257                "workflow_id": workflow_id,
258                "resume_from_seq": 42,
259            }
260        }))?;
261
262        assert_eq!(per_workflow(request)?.resume_from_seq, Some(42));
263        Ok(())
264    }
265
266    #[test]
267    fn per_workflow_resume_from_seq_zero_passes_decode_for_post_guard_validation()
268    -> Result<(), Box<dyn std::error::Error>> {
269        // Decode is presence-only; the 0-is-invalid range check belongs after
270        // the namespace guard so probes can never distinguish existence.
271        let request = decode(&json!({
272            "per_workflow": {
273                "namespace": "tenant-a",
274                "workflow_id": uuid::Uuid::from_u128(7).to_string(),
275                "resume_from_seq": 0,
276            }
277        }))?;
278
279        assert_eq!(per_workflow(request)?.resume_from_seq, Some(0));
280        Ok(())
281    }
282
283    #[test]
284    fn per_workflow_resume_from_seq_absent_or_null_is_none()
285    -> Result<(), Box<dyn std::error::Error>> {
286        let workflow_id = uuid::Uuid::from_u128(7).to_string();
287        let absent = decode(&json!({
288            "per_workflow": { "namespace": "tenant-a", "workflow_id": workflow_id }
289        }))?;
290        let null = decode(&json!({
291            "per_workflow": {
292                "namespace": "tenant-a",
293                "workflow_id": workflow_id,
294                "resume_from_seq": null,
295            }
296        }))?;
297
298        assert_eq!(per_workflow(absent)?.resume_from_seq, None);
299        assert_eq!(per_workflow(null)?.resume_from_seq, None);
300        Ok(())
301    }
302
303    #[test]
304    fn per_workflow_resume_from_seq_rejects_non_unsigned_values() {
305        for bad in [json!(-1), json!(1.5), json!("7")] {
306            let result = decode(&json!({
307                "per_workflow": {
308                    "namespace": "tenant-a",
309                    "workflow_id": uuid::Uuid::from_u128(7).to_string(),
310                    "resume_from_seq": bad,
311                }
312            }));
313            let error = result.err().map(|error| error.to_wire_error());
314            assert_eq!(
315                error.as_ref().map(|error| error.code),
316                Some(WireErrorCode::InvalidInput),
317                "expected invalid_input, got {error:?}"
318            );
319        }
320    }
321
322    #[test]
323    fn wrapped_subscription_shape_is_accepted() -> Result<(), Box<dyn std::error::Error>> {
324        let request = decode(&json!({
325            "subscription": {
326                "per_workflow": {
327                    "namespace": "tenant-a",
328                    "workflow_id": { "uuid": uuid::Uuid::from_u128(7).to_string() },
329                    "resume_from_seq": 3,
330                }
331            }
332        }))?;
333
334        let subscription = per_workflow(request)?;
335        assert_eq!(subscription.namespace, "tenant-a");
336        assert_eq!(subscription.resume_from_seq, Some(3));
337        Ok(())
338    }
339
340    #[test]
341    fn filtered_and_firehose_shapes_still_decode() -> Result<(), Box<dyn std::error::Error>> {
342        let filtered = decode(&json!({
343            "filtered": { "namespace": "tenant-a", "status": "Completed" }
344        }))?;
345        let firehose = decode(&json!({ "firehose": { "namespace": "tenant-a" } }))?;
346
347        assert!(matches!(
348            filtered.subscription,
349            Some(subscription_request::Subscription::Filtered(_))
350        ));
351        assert!(matches!(
352            firehose.subscription,
353            Some(subscription_request::Subscription::Firehose(_))
354        ));
355        Ok(())
356    }
357
358    #[test]
359    fn unknown_subscription_shape_is_invalid_input() {
360        let error = decode(&json!({ "mystery": {} }))
361            .err()
362            .map(|error| error.to_wire_error());
363        assert_eq!(
364            error.map(|error| error.code),
365            Some(WireErrorCode::InvalidInput)
366        );
367    }
368}