1use 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
18pub 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
57pub 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
125fn 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 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}