Skip to main content

fakecloud_iotdata/
service.rs

1//! AWS IoT Data Plane (`iotdata`) restJson1 dispatch + handlers.
2//!
3//! The full 11-operation IoT Data Plane model. Requests are routed to an
4//! operation by HTTP method + `@http` URI path; path labels are captured
5//! positionally (percent-decoded) and query parameters are read from the raw
6//! query string. Device-shadow and retained-message state is
7//! account-partitioned and persisted.
8
9use 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
26/// Every operation name in the AWS IoT Data Plane Smithy model (11).
27pub 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
41/// Operations that mutate persisted state on success (so a snapshot is taken).
42const 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    /// Persist hook for the CloudFormation provisioner; `None` in memory mode.
74    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    /// Route a request to an operation name + captured path labels by HTTP
89    /// method + `@http` URI path. Returns `None` when no route matches.
90    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
154/// Per-request account context.
155struct 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        // Reject a missing `@httpLabel`: an omitted required path parameter
167        // arrives as the literal `{Label}` placeholder or an empty segment.
168        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            // Connection-introspection operations. fakecloud runs no MQTT
192            // broker, so no client is ever connected: every client id
193            // faithfully resolves to `ResourceNotFoundException` (the
194            // AWS-correct response for an unknown connection) rather than a
195            // fabricated connection record.
196            "GetConnection" | "DeleteConnection" | "ListSubscriptions" | "SendDirectMessage" => {
197                Err(no_connection(a(0)))
198            }
199            _ => Err(AwsServiceError::action_not_implemented("iotdata", action)),
200        }
201    }
202
203    // ---------- device shadows ----------
204
205    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        // Optimistic-locking: a supplied `version` must match the stored one.
241        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                // Merge state.
262                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                // Stamp metadata.
269                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        // AWS returns a small deletion payload carrying the deleted version and
307        // a timestamp.
308        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    // ---------- publish + retained messages ----------
346
347    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                // An empty retained payload clears the retained message for the
363                // topic (AWS semantics).
364                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        // Publish returns an empty body (`Unit` output). No live broker fan-out.
380        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
435// ===================== shadow projection =====================
436
437/// Project the stored shadow record onto the full shadow-document wire shape:
438/// `state` (desired / reported, plus a computed `delta`), `metadata`,
439/// `version`, and `timestamp`. Empty `desired` / `reported` sections are
440/// omitted, matching AWS.
441fn 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
481// ===================== helpers =====================
482
483fn ok_json(v: Value) -> Result<AwsResponse, AwsServiceError> {
484    Ok(AwsResponse::json_value(StatusCode::OK, v))
485}
486
487/// A shadow / deletion payload response: the raw JSON document is the HTTP
488/// body (the operation's `@httpPayload` blob).
489fn 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
526/// The shadow-name key from the `name` query param (validated when present),
527/// or the classic-shadow key when absent.
528fn 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
538/// Copy a member from `src` into `out` verbatim when present and non-null.
539fn 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
547/// Read a request header value as a `String`, if present and valid UTF-8.
548fn 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
575/// Parse a bounded integer query parameter, defaulting when absent and
576/// rejecting out-of-range values with `InvalidRequestException`.
577fn 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
600/// Paginate a list using a numeric-offset `nextToken` that round-trips.
601fn 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;