Skip to main content

fakecloud_iotwireless/
service.rs

1//! AWS IoT Wireless (`iotwireless`) restJson1 dispatch.
2//!
3//! Requests are routed to one of the 112 modelled operations by HTTP method +
4//! `@http` URI template (the route table in [`crate::generated`]). Path labels
5//! are captured positionally (percent-decoded). Input is validated against the
6//! model-derived constraints ([`crate::validate`]), then handled either by the
7//! generic resource engine ([`engine`], for the create / get / list / update /
8//! delete verb of every named resource family) or by a resource-specific
9//! handler ([`special`], for tagging, position configurations, resource
10//! positions, and the wireless-device import task lookup). State is
11//! account-partitioned and persisted.
12
13use std::collections::HashMap;
14use std::sync::Arc;
15
16use async_trait::async_trait;
17use http::{Method, StatusCode};
18use percent_encoding::percent_decode_str;
19use serde_json::{Map, Value};
20use tokio::sync::Mutex as AsyncMutex;
21
22use fakecloud_core::service::{AwsRequest, AwsResponse, AwsService, AwsServiceError};
23use fakecloud_persistence::SnapshotStore;
24
25use crate::generated::{OpMeta, Seg, Verb, K, OPS};
26use crate::persistence::save_snapshot;
27use crate::state::SharedIotWirelessState;
28use crate::validate::{self, Inputs};
29
30mod engine;
31mod special;
32#[cfg(test)]
33mod tests;
34
35/// Every operation name in the AWS IoT Wireless Smithy model (112).
36pub use crate::generated::ACTIONS as IOTWIRELESS_ACTIONS;
37
38pub struct IotWirelessService {
39    state: SharedIotWirelessState,
40    snapshot_store: Option<Arc<dyn SnapshotStore>>,
41    snapshot_lock: Arc<AsyncMutex<()>>,
42}
43
44impl IotWirelessService {
45    pub fn new(state: SharedIotWirelessState) -> Self {
46        Self {
47            state,
48            snapshot_store: None,
49            snapshot_lock: Arc::new(AsyncMutex::new(())),
50        }
51    }
52
53    pub fn with_snapshot_store(mut self, store: Arc<dyn SnapshotStore>) -> Self {
54        self.snapshot_store = Some(store);
55        self
56    }
57
58    async fn save(&self) {
59        save_snapshot(
60            &self.state,
61            self.snapshot_store.clone(),
62            &self.snapshot_lock,
63        )
64        .await;
65    }
66
67    /// Persist hook for the CloudFormation provisioner; `None` in memory mode.
68    pub fn snapshot_hook(&self) -> Option<fakecloud_persistence::SnapshotHook> {
69        let store = self.snapshot_store.clone()?;
70        let state = self.state.clone();
71        let lock = self.snapshot_lock.clone();
72        Some(Arc::new(move || {
73            let state = state.clone();
74            let store = store.clone();
75            let lock = lock.clone();
76            Box::pin(async move {
77                crate::persistence::save_snapshot(&state, Some(store), &lock).await;
78            })
79        }))
80    }
81}
82
83#[async_trait]
84impl AwsService for IotWirelessService {
85    fn service_name(&self) -> &str {
86        "iotwireless"
87    }
88
89    async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
90        let Some((meta, labels)) = match_route(&req.method, &req.raw_path) else {
91            return Err(AwsServiceError::aws_error(
92                StatusCode::NOT_FOUND,
93                "ResourceNotFoundException",
94                format!("Unknown operation: {} {}", req.method, req.raw_path),
95            ));
96        };
97        let result = self.dispatch(meta, &labels, &req);
98        match result {
99            Ok((resp, mutated)) => {
100                if mutated && resp.status.is_success() {
101                    self.save().await;
102                }
103                Ok(resp)
104            }
105            Err(e) => Err(e),
106        }
107    }
108
109    fn supported_actions(&self) -> &[&str] {
110        IOTWIRELESS_ACTIONS
111    }
112}
113
114/// Per-request context.
115pub(crate) struct Ctx {
116    pub account: String,
117    pub region: String,
118}
119
120impl IotWirelessService {
121    fn dispatch(
122        &self,
123        meta: &'static OpMeta,
124        labels: &HashMap<String, String>,
125        req: &AwsRequest,
126    ) -> Result<(AwsResponse, bool), AwsServiceError> {
127        // A missing required label arrives as the literal `{Name}` placeholder.
128        for v in labels.values() {
129            if v.is_empty() || (v.starts_with('{') && v.ends_with('}')) {
130                return Err(validate::invalid(
131                    meta,
132                    "The request is missing a required path parameter.",
133                ));
134            }
135        }
136        let ctx = Ctx {
137            account: req.account_id.clone(),
138            region: if req.region.is_empty() {
139                "us-east-1".to_string()
140            } else {
141                req.region.clone()
142            },
143        };
144        let query = parse_query(&req.raw_query);
145        let body = parse_body(&req.body);
146        let inputs = Inputs {
147            labels,
148            query: &query,
149            headers: &req.headers,
150            body: &body,
151        };
152        validate::validate(meta, &inputs)?;
153
154        // Resource-specific handlers take precedence over the generic engine.
155        if let Some(res) = special::dispatch(
156            self,
157            meta,
158            &ctx,
159            labels,
160            &query,
161            &req.headers,
162            &req.body,
163            &body,
164        )? {
165            return Ok(res);
166        }
167
168        // Generic resource engine by verb.
169        match meta.verb {
170            Verb::Create => {
171                let mut g = self.state.write();
172                let data = g.get_or_create(&ctx.account);
173                Ok((
174                    engine::create(data, &ctx, meta, labels, &query, &body)?,
175                    true,
176                ))
177            }
178            Verb::Update => {
179                let mut g = self.state.write();
180                let data = g.get_or_create(&ctx.account);
181                Ok((
182                    engine::update(data, &ctx, meta, labels, &query, &body),
183                    true,
184                ))
185            }
186            Verb::Delete => {
187                let mut g = self.state.write();
188                let data = g.get_or_create(&ctx.account);
189                Ok((engine::delete(data, meta, labels), true))
190            }
191            Verb::Get => {
192                let g = self.state.read();
193                let data = g.get(&ctx.account);
194                Ok((engine::get(data, meta, labels)?, false))
195            }
196            Verb::List => {
197                let g = self.state.read();
198                let data = g.get(&ctx.account);
199                Ok((engine::list(data, meta, &query), false))
200            }
201            Verb::Action => {
202                // Read-only actions not claimed by `special` return an empty
203                // (shape-valid) body. An unclaimed *mutating* action must fail
204                // loud rather than silently accept-and-discard, so future model
205                // gaps surface instead of appearing to persist.
206                if is_read_only_action(meta) {
207                    Ok((ok_json(Value::Object(Map::new())), false))
208                } else {
209                    Err(AwsServiceError::action_not_implemented(
210                        "iotwireless",
211                        meta.op,
212                    ))
213                }
214            }
215        }
216    }
217}
218
219// ===================== routing =====================
220
221/// Match a request `(method, path)` against the generated route table,
222/// returning the operation metadata and captured labels (member name -> value).
223/// Ties (a fixed segment vs a label segment at the same position) are broken in
224/// favour of the route with more fixed segments — the more specific route.
225pub(crate) fn match_route(
226    method: &Method,
227    raw_path: &str,
228) -> Option<(&'static OpMeta, HashMap<String, String>)> {
229    let raw = raw_path.split('?').next().unwrap_or(raw_path);
230    let trimmed = raw.strip_prefix('/').unwrap_or(raw);
231    let segs: Vec<String> = if trimmed.is_empty() {
232        Vec::new()
233    } else {
234        trimmed
235            .split('/')
236            .map(|s| percent_decode_str(s).decode_utf8_lossy().into_owned())
237            .collect()
238    };
239    let method_str = method.as_str();
240
241    let mut best: Option<(&'static OpMeta, HashMap<String, String>, usize)> = None;
242    for meta in OPS {
243        if meta.method != method_str {
244            continue;
245        }
246        if let Some((labels, fixed)) = match_segs(meta.segs, &segs) {
247            if best.as_ref().map(|(_, _, f)| fixed > *f).unwrap_or(true) {
248                best = Some((meta, labels, fixed));
249            }
250        }
251    }
252    best.map(|(m, l, _)| (m, l))
253}
254
255/// Try to match a route's segment pattern against the request segments,
256/// returning captured labels and the fixed-segment count on success.
257fn match_segs(pattern: &[Seg], segs: &[String]) -> Option<(HashMap<String, String>, usize)> {
258    let has_greedy = matches!(pattern.last(), Some(Seg::Greedy(_)));
259    if has_greedy {
260        if segs.len() < pattern.len() {
261            return None;
262        }
263    } else if segs.len() != pattern.len() {
264        return None;
265    }
266
267    let mut labels = HashMap::new();
268    let mut fixed = 0usize;
269    let mut i = 0usize;
270    for (p, seg) in pattern.iter().enumerate() {
271        match seg {
272            Seg::Fixed(f) => {
273                if segs.get(i)?.as_str() != *f {
274                    return None;
275                }
276                fixed += 1;
277                i += 1;
278            }
279            Seg::Label(name) => {
280                labels.insert((*name).to_string(), segs.get(i)?.clone());
281                i += 1;
282            }
283            Seg::Greedy(name) => {
284                // Greedy is always the last segment; capture the rest.
285                debug_assert_eq!(p, pattern.len() - 1);
286                let rest = segs[i..].join("/");
287                labels.insert((*name).to_string(), rest);
288                i = segs.len();
289            }
290        }
291    }
292    if i != segs.len() {
293        return None;
294    }
295    Some((labels, fixed))
296}
297
298// ===================== shared helpers =====================
299
300pub(crate) fn ok_json(v: Value) -> AwsResponse {
301    AwsResponse::json_value(StatusCode::OK, v)
302}
303
304/// Whether an unclaimed `Verb::Action` operation is safe to accept as an empty
305/// no-op. Read operations (HTTP GET, or a `Get`/`List`/`Describe` name) have no
306/// persisted side effect, so an empty body is shape-valid; anything else mutates
307/// and must fail loud instead of silently discarding the request.
308pub(crate) fn is_read_only_action(meta: &OpMeta) -> bool {
309    meta.method == "GET"
310        || ["Get", "List", "Describe", "Search"]
311            .iter()
312            .any(|p| meta.op.starts_with(p))
313}
314
315pub(crate) fn parse_query(raw: &str) -> Vec<(String, String)> {
316    raw.split('&')
317        .filter(|p| !p.is_empty())
318        .map(|pair| {
319            let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
320            (
321                percent_decode_str(k).decode_utf8_lossy().into_owned(),
322                percent_decode_str(v).decode_utf8_lossy().into_owned(),
323            )
324        })
325        .collect()
326}
327
328pub(crate) fn parse_body(body: &[u8]) -> Map<String, Value> {
329    if body.is_empty() {
330        return Map::new();
331    }
332    match serde_json::from_slice::<Value>(body) {
333        Ok(Value::Object(m)) => m,
334        _ => Map::new(),
335    }
336}
337
338/// Current time as a restJson1 timestamp: Unix epoch **seconds** encoded as a
339/// JSON number, with fractional milliseconds preserved (e.g. `1752324947.041`).
340/// This is the default `timestamp` wire format for the `restJson1` protocol
341/// (which AWS IoT Wireless uses), and is what the aws-sdk / aws-smithy timestamp
342/// deserializer expects — an RFC3339 string would be rejected.
343pub(crate) fn now_epoch() -> Value {
344    let millis = chrono::Utc::now().timestamp_millis();
345    Value::from(millis as f64 / 1000.0)
346}
347
348pub(crate) fn query_get<'a>(q: &'a [(String, String)], key: &str) -> Option<&'a str> {
349    q.iter()
350        .find(|(k, _)| k == key)
351        .map(|(_, v)| v.as_str())
352        .filter(|v| !v.is_empty())
353}
354
355/// Canonical resource type for a named-resource operation: the operation's
356/// fixed URI segments joined by `/`. Because a create is a collection POST
357/// (`POST /destinations`) and its read/update/delete carry a trailing path
358/// label (`GET /destinations/{Name}`), joining only the fixed segments yields
359/// the same key for every verb of a family.
360pub(crate) fn resource_type(meta: &OpMeta) -> String {
361    meta.segs
362        .iter()
363        .filter_map(|s| match s {
364            Seg::Fixed(f) => Some(*f),
365            _ => None,
366        })
367        .collect::<Vec<_>>()
368        .join("/")
369}
370
371/// The composite storage key for a request addressed by a URI label: its label
372/// values in URI order.
373pub(crate) fn storage_key(meta: &OpMeta, labels: &HashMap<String, String>) -> String {
374    let mut parts = Vec::new();
375    for s in meta.segs {
376        match s {
377            Seg::Label(name) | Seg::Greedy(name) => {
378                if let Some(v) = labels.get(*name) {
379                    parts.push(v.clone());
380                }
381            }
382            _ => {}
383        }
384    }
385    parts.join("/")
386}
387
388/// The AWS ARN resource-type token for a stored resource family.
389pub(crate) fn arn_resource(rtype: &str) -> &'static str {
390    match rtype {
391        "destinations" => "Destination",
392        "device-profiles" => "DeviceProfile",
393        "service-profiles" => "ServiceProfile",
394        "fuota-tasks" => "FuotaTask",
395        "multicast-groups" => "MulticastGroup",
396        "network-analyzer-configurations" => "NetworkAnalyzerConfig",
397        "wireless-devices" => "WirelessDevice",
398        "wireless-gateways" => "WirelessGateway",
399        "wireless-gateway-task-definitions" => "WirelessGatewayTaskDefinition",
400        "wireless_device_import_task" => "ImportTask",
401        "partner-accounts" => "PartnerAccount",
402        _ => "Resource",
403    }
404}
405
406pub(crate) fn mint_arn(ctx: &Ctx, rtype: &str, id: &str) -> String {
407    format!(
408        "arn:aws:iotwireless:{}:{}:{}/{}",
409        ctx.region,
410        ctx.account,
411        arn_resource(rtype),
412        id
413    )
414}
415
416/// Deterministic UUID-shaped id derived from a seed string.
417pub(crate) fn mint_uuid(seed: &str) -> String {
418    let h = fnv(seed);
419    let h2 = fnv(&format!("{seed}:2"));
420    format!(
421        "{:08x}-{:04x}-{:04x}-{:04x}-{:012x}",
422        (h >> 32) as u32,
423        (h >> 16) as u16,
424        h as u16,
425        (h2 >> 48) as u16,
426        h2 & 0xffff_ffff_ffff
427    )
428}
429
430fn fnv(s: &str) -> u64 {
431    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
432    for b in s.bytes() {
433        h ^= b as u64;
434        h = h.wrapping_mul(0x0100_0000_01b3);
435    }
436    h
437}
438
439/// Whether a JSON value's type is compatible with a modelled member kind.
440pub(crate) fn kind_matches(kind: K, v: &Value) -> bool {
441    match kind {
442        K::Str | K::Blob => v.is_string(),
443        // restJson1 timestamps wire-encode as epoch-seconds JSON numbers; accept
444        // a string too so any legacy/ISO stored value still projects.
445        K::Ts => v.is_string() || v.is_number(),
446        K::Int | K::Num => v.is_number(),
447        K::Bool => v.is_boolean(),
448        K::List => v.is_array(),
449        K::Map | K::Struct => v.is_object(),
450    }
451}
452
453/// Project a stored record onto an operation's output members: keep only
454/// members present in the record whose JSON type matches the modelled kind.
455pub(crate) fn build_output(meta: &OpMeta, record: &Value) -> Value {
456    let mut out = Map::new();
457    if let Some(obj) = record.as_object() {
458        for (wire, kind) in meta.omembers {
459            if let Some(v) = obj.get(*wire) {
460                if !v.is_null() && kind_matches(*kind, v) {
461                    out.insert((*wire).to_string(), v.clone());
462                }
463            }
464        }
465    }
466    Value::Object(out)
467}
468
469/// Project a record onto a list operation's element members.
470pub(crate) fn build_element(meta: &OpMeta, record: &Value) -> Value {
471    let mut out = Map::new();
472    if let Some(obj) = record.as_object() {
473        for (wire, kind) in meta.list_elems {
474            if let Some(v) = obj.get(*wire) {
475                if !v.is_null() && kind_matches(*kind, v) {
476                    out.insert((*wire).to_string(), v.clone());
477                }
478            }
479        }
480    }
481    Value::Object(out)
482}