1use std::sync::Arc;
10
11use async_trait::async_trait;
12use base64::Engine;
13use http::{Method, StatusCode};
14use percent_encoding::percent_decode_str;
15use serde_json::{json, Map, Value};
16use tokio::sync::Mutex as AsyncMutex;
17
18use fakecloud_core::service::{AwsRequest, AwsResponse, AwsService, AwsServiceError};
19use fakecloud_persistence::SnapshotStore;
20
21use crate::persistence::save_snapshot;
22use crate::shared;
23use crate::state::{SharedIotDataState, CLASSIC_SHADOW_KEY};
24use crate::validate;
25
26pub const IOTDATA_ACTIONS: &[&str] = &[
28 "DeleteConnection",
29 "DeleteThingShadow",
30 "GetConnection",
31 "GetRetainedMessage",
32 "GetThingShadow",
33 "ListNamedShadowsForThing",
34 "ListRetainedMessages",
35 "ListSubscriptions",
36 "Publish",
37 "SendDirectMessage",
38 "UpdateThingShadow",
39];
40
41const MUTATING: &[&str] = &["DeleteThingShadow", "Publish", "UpdateThingShadow"];
43
44pub struct IotDataService {
45 state: SharedIotDataState,
46 snapshot_store: Option<Arc<dyn SnapshotStore>>,
47 snapshot_lock: Arc<AsyncMutex<()>>,
48}
49
50impl IotDataService {
51 pub fn new(state: SharedIotDataState) -> Self {
52 Self {
53 state,
54 snapshot_store: None,
55 snapshot_lock: Arc::new(AsyncMutex::new(())),
56 }
57 }
58
59 pub fn with_snapshot_store(mut self, store: Arc<dyn SnapshotStore>) -> Self {
60 self.snapshot_store = Some(store);
61 self
62 }
63
64 async fn save(&self) {
65 save_snapshot(
66 &self.state,
67 self.snapshot_store.clone(),
68 &self.snapshot_lock,
69 )
70 .await;
71 }
72
73 pub fn snapshot_hook(&self) -> Option<fakecloud_persistence::SnapshotHook> {
75 let store = self.snapshot_store.clone()?;
76 let state = self.state.clone();
77 let lock = self.snapshot_lock.clone();
78 Some(Arc::new(move || {
79 let state = state.clone();
80 let store = store.clone();
81 let lock = lock.clone();
82 Box::pin(async move {
83 crate::persistence::save_snapshot(&state, Some(store), &lock).await;
84 })
85 }))
86 }
87
88 fn resolve_action(req: &AwsRequest) -> Option<(&'static str, Vec<String>)> {
91 let raw = req.raw_path.split('?').next().unwrap_or(&req.raw_path);
92 let trimmed = raw.strip_prefix('/').unwrap_or(raw);
93 let segs: Vec<String> = if trimmed.is_empty() {
94 Vec::new()
95 } else {
96 trimmed
97 .split('/')
98 .map(|s| percent_decode_str(s).decode_utf8_lossy().into_owned())
99 .collect()
100 };
101 let s: Vec<&str> = segs.iter().map(String::as_str).collect();
102 let m = &req.method;
103 let get = m == Method::GET;
104 let post = m == Method::POST;
105 let del = m == Method::DELETE;
106 let l1 = |a: &str| vec![a.to_string()];
107 let (action, labels): (&'static str, Vec<String>) = match s.as_slice() {
108 ["things", name, "shadow"] if get => ("GetThingShadow", l1(name)),
109 ["things", name, "shadow"] if post => ("UpdateThingShadow", l1(name)),
110 ["things", name, "shadow"] if del => ("DeleteThingShadow", l1(name)),
111 ["api", "things", "shadow", "ListNamedShadowsForThing", name] if get => {
112 ("ListNamedShadowsForThing", l1(name))
113 }
114 ["topics", topic] if post => ("Publish", l1(topic)),
115 ["retainedMessage"] if get => ("ListRetainedMessages", vec![]),
116 ["retainedMessage", topic] if get => ("GetRetainedMessage", l1(topic)),
117 ["connections", client] if get => ("GetConnection", l1(client)),
118 ["connections", client] if del => ("DeleteConnection", l1(client)),
119 ["connections", client, "subscriptions"] if get => ("ListSubscriptions", l1(client)),
120 ["connections", client, "messages"] if post => ("SendDirectMessage", l1(client)),
121 _ => return None,
122 };
123 Some((action, labels))
124 }
125}
126
127#[async_trait]
128impl AwsService for IotDataService {
129 fn service_name(&self) -> &str {
130 "iotdata"
131 }
132
133 async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
134 let Some((action, labels)) = Self::resolve_action(&req) else {
135 return Err(AwsServiceError::aws_error(
136 StatusCode::NOT_FOUND,
137 "ResourceNotFoundException",
138 format!("Unknown operation: {} {}", req.method, req.raw_path),
139 ));
140 };
141 let result = self.dispatch(action, &labels, &req);
142 let success = matches!(result.as_ref(), Ok(resp) if resp.status.is_success());
143 if MUTATING.contains(&action) && success {
144 self.save().await;
145 }
146 result
147 }
148
149 fn supported_actions(&self) -> &[&str] {
150 IOTDATA_ACTIONS
151 }
152}
153
154struct Ctx {
156 account: String,
157}
158
159impl IotDataService {
160 fn dispatch(
161 &self,
162 action: &str,
163 labels: &[String],
164 req: &AwsRequest,
165 ) -> Result<AwsResponse, AwsServiceError> {
166 for label in labels {
169 if label.is_empty() || (label.starts_with('{') && label.ends_with('}')) {
170 return Err(invalid_request(
171 "The request is missing a required path parameter.",
172 ));
173 }
174 }
175 let ctx = Ctx {
176 account: req.account_id.clone(),
177 };
178 let q = parse_query(&req.raw_query);
179 let a = |i: usize| labels.get(i).map(String::as_str).unwrap_or_default();
180 match action {
181 "GetThingShadow" => self.get_thing_shadow(&ctx, a(0), &q),
182 "UpdateThingShadow" => self.update_thing_shadow(&ctx, a(0), &q, &req.body),
183 "DeleteThingShadow" => self.delete_thing_shadow(&ctx, a(0), &q),
184 "ListNamedShadowsForThing" => self.list_named_shadows(&ctx, a(0), &q),
185 "Publish" => {
186 let pfi = header_value(req, "x-amz-mqtt5-payload-format-indicator");
187 self.publish(&ctx, a(0), &q, pfi.as_deref(), &req.body)
188 }
189 "GetRetainedMessage" => self.get_retained_message(&ctx, a(0)),
190 "ListRetainedMessages" => self.list_retained_messages(&ctx, &q),
191 "GetConnection" | "DeleteConnection" | "ListSubscriptions" | "SendDirectMessage" => {
197 Err(no_connection(a(0)))
198 }
199 _ => Err(AwsServiceError::action_not_implemented("iotdata", action)),
200 }
201 }
202
203 fn get_thing_shadow(
206 &self,
207 ctx: &Ctx,
208 thing: &str,
209 q: &[(String, String)],
210 ) -> Result<AwsResponse, AwsServiceError> {
211 validate::validate_thing_name(thing)?;
212 let shadow_name = shadow_key(q)?;
213 let guard = self.state.read();
214 let stored = guard
215 .get(&ctx.account)
216 .and_then(|d| d.shadows.get(thing))
217 .and_then(|m| m.get(&shadow_name))
218 .ok_or_else(no_shadow)?;
219 payload_ok(&project_shadow(stored))
220 }
221
222 fn update_thing_shadow(
223 &self,
224 ctx: &Ctx,
225 thing: &str,
226 q: &[(String, String)],
227 body: &[u8],
228 ) -> Result<AwsResponse, AwsServiceError> {
229 validate::validate_thing_name(thing)?;
230 let doc = validate::validate_shadow_update(body)?;
231 let shadow_name = shadow_key(q)?;
232 let requested_version = doc.get("version").and_then(Value::as_i64);
233 let update_state = doc.get("state").cloned().unwrap_or_else(|| json!({}));
234
235 let mut guard = self.state.write();
236 let data = guard.get_or_create(&ctx.account);
237 let thing_shadows = data.shadows.entry(thing.to_string()).or_default();
238 let existing = thing_shadows.get(&shadow_name).cloned();
239
240 if let (Some(req_ver), Some(current)) = (requested_version, existing.as_ref()) {
242 let cur_ver = current.get("version").and_then(Value::as_i64).unwrap_or(0);
243 if req_ver != cur_ver {
244 return Err(conflict(&format!(
245 "Version conflict: supplied {req_ver}, current {cur_ver}."
246 )));
247 }
248 }
249
250 let ts = shared::now_secs();
251 let mut stored = existing.unwrap_or_else(|| {
252 json!({
253 "state": { "desired": {}, "reported": {} },
254 "metadata": { "desired": {}, "reported": {} },
255 "version": 0,
256 })
257 });
258
259 for section in ["desired", "reported"] {
260 if let Some(patch) = update_state.get(section) {
261 let state_section = stored["state"]
263 .as_object_mut()
264 .expect("state object")
265 .entry(section.to_string())
266 .or_insert_with(|| json!({}));
267 shared::merge_into(state_section, patch);
268 let meta_section = stored["metadata"]
270 .as_object_mut()
271 .expect("metadata object")
272 .entry(section.to_string())
273 .or_insert_with(|| json!({}));
274 shared::stamp_metadata(meta_section, patch, ts);
275 }
276 }
277
278 let new_version = stored.get("version").and_then(Value::as_i64).unwrap_or(0) + 1;
279 stored["version"] = json!(new_version);
280 stored["timestamp"] = json!(ts);
281
282 let response = project_shadow(&stored);
283 thing_shadows.insert(shadow_name, stored);
284 payload_ok(&response)
285 }
286
287 fn delete_thing_shadow(
288 &self,
289 ctx: &Ctx,
290 thing: &str,
291 q: &[(String, String)],
292 ) -> Result<AwsResponse, AwsServiceError> {
293 validate::validate_thing_name(thing)?;
294 let shadow_name = shadow_key(q)?;
295 let mut guard = self.state.write();
296 let data = guard.get_or_create(&ctx.account);
297 let Some(thing_shadows) = data.shadows.get_mut(thing) else {
298 return Err(no_shadow());
299 };
300 let Some(removed) = thing_shadows.remove(&shadow_name) else {
301 return Err(no_shadow());
302 };
303 if thing_shadows.is_empty() {
304 data.shadows.remove(thing);
305 }
306 let version = removed.get("version").and_then(Value::as_i64).unwrap_or(0);
309 payload_ok(&json!({ "version": version, "timestamp": shared::now_secs() }))
310 }
311
312 fn list_named_shadows(
313 &self,
314 ctx: &Ctx,
315 thing: &str,
316 q: &[(String, String)],
317 ) -> Result<AwsResponse, AwsServiceError> {
318 validate::validate_thing_name(thing)?;
319 let guard = self.state.read();
320 let mut names: Vec<String> = guard
321 .get(&ctx.account)
322 .and_then(|d| d.shadows.get(thing))
323 .map(|m| {
324 m.keys()
325 .filter(|k| k.as_str() != CLASSIC_SHADOW_KEY)
326 .cloned()
327 .collect()
328 })
329 .unwrap_or_default();
330 names.sort();
331 let page_size = parse_bounded(q, "pageSize", 1, 100, 100)?;
332 let (page, next) = paginate(names, q, "nextToken", page_size)?;
333 let mut out = Map::new();
334 out.insert(
335 "results".into(),
336 Value::Array(page.into_iter().map(Value::String).collect()),
337 );
338 if let Some(n) = next {
339 out.insert("nextToken".into(), json!(n));
340 }
341 out.insert("timestamp".into(), json!(shared::now_secs()));
342 ok_json(Value::Object(out))
343 }
344
345 fn publish(
348 &self,
349 ctx: &Ctx,
350 topic: &str,
351 q: &[(String, String)],
352 payload_format_indicator: Option<&str>,
353 body: &[u8],
354 ) -> Result<AwsResponse, AwsServiceError> {
355 validate::validate_payload_format_indicator(payload_format_indicator)?;
356 let qos = parse_bounded(q, "qos", 0, 1, 0)?;
357 let retain = matches!(query_one(q, "retain"), Some("true"));
358 if retain {
359 let mut guard = self.state.write();
360 let data = guard.get_or_create(&ctx.account);
361 if body.is_empty() {
362 data.retained.remove(topic);
365 } else {
366 let encoded = base64::engine::general_purpose::STANDARD.encode(body);
367 data.retained.insert(
368 topic.to_string(),
369 json!({
370 "topic": topic,
371 "payload": encoded,
372 "payloadSize": body.len() as i64,
373 "qos": qos,
374 "lastModifiedTime": shared::now_millis(),
375 }),
376 );
377 }
378 }
379 Ok(AwsResponse::json(StatusCode::OK, ""))
381 }
382
383 fn get_retained_message(&self, ctx: &Ctx, topic: &str) -> Result<AwsResponse, AwsServiceError> {
384 let guard = self.state.read();
385 let msg = guard
386 .get(&ctx.account)
387 .and_then(|d| d.retained.get(topic))
388 .ok_or_else(|| no_retained(topic))?;
389 let mut out = Map::new();
390 out.insert("topic".into(), json!(topic));
391 copy_present(&mut out, msg, "payload");
392 copy_present(&mut out, msg, "qos");
393 copy_present(&mut out, msg, "lastModifiedTime");
394 ok_json(Value::Object(out))
395 }
396
397 fn list_retained_messages(
398 &self,
399 ctx: &Ctx,
400 q: &[(String, String)],
401 ) -> Result<AwsResponse, AwsServiceError> {
402 let guard = self.state.read();
403 let mut items: Vec<Value> = guard
404 .get(&ctx.account)
405 .map(|d| {
406 d.retained
407 .values()
408 .map(|m| {
409 let mut summary = Map::new();
410 copy_present(&mut summary, m, "topic");
411 copy_present(&mut summary, m, "payloadSize");
412 copy_present(&mut summary, m, "qos");
413 copy_present(&mut summary, m, "lastModifiedTime");
414 Value::Object(summary)
415 })
416 .collect()
417 })
418 .unwrap_or_default();
419 items.sort_by(|a, b| {
420 a.get("topic")
421 .and_then(Value::as_str)
422 .cmp(&b.get("topic").and_then(Value::as_str))
423 });
424 let max = parse_bounded(q, "maxResults", 1, 200, 200)?;
425 let (page, next) = paginate(items, q, "nextToken", max)?;
426 let mut out = Map::new();
427 out.insert("retainedTopics".into(), json!(page));
428 if let Some(n) = next {
429 out.insert("nextToken".into(), json!(n));
430 }
431 ok_json(Value::Object(out))
432 }
433}
434
435fn project_shadow(stored: &Value) -> Value {
442 let empty = Map::new();
443 let state_in = stored
444 .get("state")
445 .and_then(Value::as_object)
446 .unwrap_or(&empty);
447 let desired = state_in
448 .get("desired")
449 .cloned()
450 .unwrap_or_else(|| json!({}));
451 let reported = state_in
452 .get("reported")
453 .cloned()
454 .unwrap_or_else(|| json!({}));
455
456 let mut state_out = Map::new();
457 if desired.as_object().map(|o| !o.is_empty()).unwrap_or(false) {
458 state_out.insert("desired".into(), desired.clone());
459 }
460 if reported.as_object().map(|o| !o.is_empty()).unwrap_or(false) {
461 state_out.insert("reported".into(), reported.clone());
462 }
463 if let Some(delta) = shared::compute_delta(&desired, &reported) {
464 state_out.insert("delta".into(), delta);
465 }
466
467 let mut out = Map::new();
468 out.insert("state".into(), Value::Object(state_out));
469 if let Some(meta) = stored.get("metadata") {
470 out.insert("metadata".into(), meta.clone());
471 }
472 if let Some(v) = stored.get("version") {
473 out.insert("version".into(), v.clone());
474 }
475 if let Some(t) = stored.get("timestamp") {
476 out.insert("timestamp".into(), t.clone());
477 }
478 Value::Object(out)
479}
480
481fn ok_json(v: Value) -> Result<AwsResponse, AwsServiceError> {
484 Ok(AwsResponse::json_value(StatusCode::OK, v))
485}
486
487fn payload_ok(v: &Value) -> Result<AwsResponse, AwsServiceError> {
490 let bytes = serde_json::to_vec(v).expect("serde_json::Value serialization is infallible");
491 Ok(AwsResponse::json(StatusCode::OK, bytes))
492}
493
494fn invalid_request(msg: &str) -> AwsServiceError {
495 validate::invalid_request(msg)
496}
497
498fn conflict(msg: &str) -> AwsServiceError {
499 AwsServiceError::aws_error(StatusCode::CONFLICT, "ConflictException", msg)
500}
501
502fn no_shadow() -> AwsServiceError {
503 AwsServiceError::aws_error(
504 StatusCode::NOT_FOUND,
505 "ResourceNotFoundException",
506 "No shadow exists with name: ",
507 )
508}
509
510fn no_retained(topic: &str) -> AwsServiceError {
511 AwsServiceError::aws_error(
512 StatusCode::NOT_FOUND,
513 "ResourceNotFoundException",
514 format!("No retained message found for topic '{topic}'."),
515 )
516}
517
518fn no_connection(client: &str) -> AwsServiceError {
519 AwsServiceError::aws_error(
520 StatusCode::NOT_FOUND,
521 "ResourceNotFoundException",
522 format!("No connection found for client id '{client}'."),
523 )
524}
525
526fn shadow_key(q: &[(String, String)]) -> Result<String, AwsServiceError> {
529 match query_one(q, "name") {
530 Some(name) => {
531 validate::validate_shadow_name(name)?;
532 Ok(name.to_string())
533 }
534 None => Ok(CLASSIC_SHADOW_KEY.to_string()),
535 }
536}
537
538fn copy_present(out: &mut Map<String, Value>, src: &Value, key: &str) {
540 if let Some(v) = src.get(key) {
541 if !v.is_null() {
542 out.insert(key.to_string(), v.clone());
543 }
544 }
545}
546
547fn header_value(req: &AwsRequest, name: &str) -> Option<String> {
549 req.headers
550 .get(name)
551 .and_then(|v| v.to_str().ok())
552 .map(str::to_string)
553}
554
555fn parse_query(raw: &str) -> Vec<(String, String)> {
556 raw.split('&')
557 .filter(|p| !p.is_empty())
558 .map(|pair| {
559 let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
560 (
561 percent_decode_str(k).decode_utf8_lossy().into_owned(),
562 percent_decode_str(v).decode_utf8_lossy().into_owned(),
563 )
564 })
565 .collect()
566}
567
568fn query_one<'a>(q: &'a [(String, String)], key: &str) -> Option<&'a str> {
569 q.iter()
570 .find(|(k, _)| k == key)
571 .map(|(_, v)| v.as_str())
572 .filter(|v| !v.is_empty())
573}
574
575fn parse_bounded(
578 q: &[(String, String)],
579 key: &str,
580 min: i64,
581 max: i64,
582 default: i64,
583) -> Result<i64, AwsServiceError> {
584 match query_one(q, key) {
585 None => Ok(default),
586 Some(v) => {
587 let n: i64 = v
588 .parse()
589 .map_err(|_| invalid_request(&format!("{key} must be an integer.")))?;
590 if !(min..=max).contains(&n) {
591 return Err(invalid_request(&format!(
592 "{key} must be between {min} and {max}."
593 )));
594 }
595 Ok(n)
596 }
597 }
598}
599
600fn paginate<T: Clone>(
602 items: Vec<T>,
603 q: &[(String, String)],
604 token_key: &str,
605 max: i64,
606) -> Result<(Vec<T>, Option<String>), AwsServiceError> {
607 let start = query_one(q, token_key)
608 .and_then(|v| v.parse::<usize>().ok())
609 .unwrap_or(0);
610 let max = max as usize;
611 let end = (start + max).min(items.len());
612 let page: Vec<T> = items.get(start..end).unwrap_or(&[]).to_vec();
613 let next = if end < items.len() {
614 Some(end.to_string())
615 } else {
616 None
617 };
618 Ok((page, next))
619}
620
621#[cfg(test)]
622mod tests;