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                // Any action not claimed by `special` is accepted as a
203                // control-plane no-op returning an empty (shape-valid) body.
204                Ok((ok_json(Value::Object(Map::new())), false))
205            }
206        }
207    }
208}
209
210// ===================== routing =====================
211
212/// Match a request `(method, path)` against the generated route table,
213/// returning the operation metadata and captured labels (member name -> value).
214/// Ties (a fixed segment vs a label segment at the same position) are broken in
215/// favour of the route with more fixed segments — the more specific route.
216pub(crate) fn match_route(
217    method: &Method,
218    raw_path: &str,
219) -> Option<(&'static OpMeta, HashMap<String, String>)> {
220    let raw = raw_path.split('?').next().unwrap_or(raw_path);
221    let trimmed = raw.strip_prefix('/').unwrap_or(raw);
222    let segs: Vec<String> = if trimmed.is_empty() {
223        Vec::new()
224    } else {
225        trimmed
226            .split('/')
227            .map(|s| percent_decode_str(s).decode_utf8_lossy().into_owned())
228            .collect()
229    };
230    let method_str = method.as_str();
231
232    let mut best: Option<(&'static OpMeta, HashMap<String, String>, usize)> = None;
233    for meta in OPS {
234        if meta.method != method_str {
235            continue;
236        }
237        if let Some((labels, fixed)) = match_segs(meta.segs, &segs) {
238            if best.as_ref().map(|(_, _, f)| fixed > *f).unwrap_or(true) {
239                best = Some((meta, labels, fixed));
240            }
241        }
242    }
243    best.map(|(m, l, _)| (m, l))
244}
245
246/// Try to match a route's segment pattern against the request segments,
247/// returning captured labels and the fixed-segment count on success.
248fn match_segs(pattern: &[Seg], segs: &[String]) -> Option<(HashMap<String, String>, usize)> {
249    let has_greedy = matches!(pattern.last(), Some(Seg::Greedy(_)));
250    if has_greedy {
251        if segs.len() < pattern.len() {
252            return None;
253        }
254    } else if segs.len() != pattern.len() {
255        return None;
256    }
257
258    let mut labels = HashMap::new();
259    let mut fixed = 0usize;
260    let mut i = 0usize;
261    for (p, seg) in pattern.iter().enumerate() {
262        match seg {
263            Seg::Fixed(f) => {
264                if segs.get(i)?.as_str() != *f {
265                    return None;
266                }
267                fixed += 1;
268                i += 1;
269            }
270            Seg::Label(name) => {
271                labels.insert((*name).to_string(), segs.get(i)?.clone());
272                i += 1;
273            }
274            Seg::Greedy(name) => {
275                // Greedy is always the last segment; capture the rest.
276                debug_assert_eq!(p, pattern.len() - 1);
277                let rest = segs[i..].join("/");
278                labels.insert((*name).to_string(), rest);
279                i = segs.len();
280            }
281        }
282    }
283    if i != segs.len() {
284        return None;
285    }
286    Some((labels, fixed))
287}
288
289// ===================== shared helpers =====================
290
291pub(crate) fn ok_json(v: Value) -> AwsResponse {
292    AwsResponse::json_value(StatusCode::OK, v)
293}
294
295pub(crate) fn parse_query(raw: &str) -> Vec<(String, String)> {
296    raw.split('&')
297        .filter(|p| !p.is_empty())
298        .map(|pair| {
299            let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
300            (
301                percent_decode_str(k).decode_utf8_lossy().into_owned(),
302                percent_decode_str(v).decode_utf8_lossy().into_owned(),
303            )
304        })
305        .collect()
306}
307
308pub(crate) fn parse_body(body: &[u8]) -> Map<String, Value> {
309    if body.is_empty() {
310        return Map::new();
311    }
312    match serde_json::from_slice::<Value>(body) {
313        Ok(Value::Object(m)) => m,
314        _ => Map::new(),
315    }
316}
317
318/// Current time as a restJson1 timestamp: Unix epoch **seconds** encoded as a
319/// JSON number, with fractional milliseconds preserved (e.g. `1752324947.041`).
320/// This is the default `timestamp` wire format for the `restJson1` protocol
321/// (which AWS IoT Wireless uses), and is what the aws-sdk / aws-smithy timestamp
322/// deserializer expects — an RFC3339 string would be rejected.
323pub(crate) fn now_epoch() -> Value {
324    let millis = chrono::Utc::now().timestamp_millis();
325    Value::from(millis as f64 / 1000.0)
326}
327
328pub(crate) fn query_get<'a>(q: &'a [(String, String)], key: &str) -> Option<&'a str> {
329    q.iter()
330        .find(|(k, _)| k == key)
331        .map(|(_, v)| v.as_str())
332        .filter(|v| !v.is_empty())
333}
334
335/// Canonical resource type for a named-resource operation: the operation's
336/// fixed URI segments joined by `/`. Because a create is a collection POST
337/// (`POST /destinations`) and its read/update/delete carry a trailing path
338/// label (`GET /destinations/{Name}`), joining only the fixed segments yields
339/// the same key for every verb of a family.
340pub(crate) fn resource_type(meta: &OpMeta) -> String {
341    meta.segs
342        .iter()
343        .filter_map(|s| match s {
344            Seg::Fixed(f) => Some(*f),
345            _ => None,
346        })
347        .collect::<Vec<_>>()
348        .join("/")
349}
350
351/// The composite storage key for a request addressed by a URI label: its label
352/// values in URI order.
353pub(crate) fn storage_key(meta: &OpMeta, labels: &HashMap<String, String>) -> String {
354    let mut parts = Vec::new();
355    for s in meta.segs {
356        match s {
357            Seg::Label(name) | Seg::Greedy(name) => {
358                if let Some(v) = labels.get(*name) {
359                    parts.push(v.clone());
360                }
361            }
362            _ => {}
363        }
364    }
365    parts.join("/")
366}
367
368/// The AWS ARN resource-type token for a stored resource family.
369pub(crate) fn arn_resource(rtype: &str) -> &'static str {
370    match rtype {
371        "destinations" => "Destination",
372        "device-profiles" => "DeviceProfile",
373        "service-profiles" => "ServiceProfile",
374        "fuota-tasks" => "FuotaTask",
375        "multicast-groups" => "MulticastGroup",
376        "network-analyzer-configurations" => "NetworkAnalyzerConfig",
377        "wireless-devices" => "WirelessDevice",
378        "wireless-gateways" => "WirelessGateway",
379        "wireless-gateway-task-definitions" => "WirelessGatewayTaskDefinition",
380        "wireless_device_import_task" => "ImportTask",
381        "partner-accounts" => "PartnerAccount",
382        _ => "Resource",
383    }
384}
385
386pub(crate) fn mint_arn(ctx: &Ctx, rtype: &str, id: &str) -> String {
387    format!(
388        "arn:aws:iotwireless:{}:{}:{}/{}",
389        ctx.region,
390        ctx.account,
391        arn_resource(rtype),
392        id
393    )
394}
395
396/// Deterministic UUID-shaped id derived from a seed string.
397pub(crate) fn mint_uuid(seed: &str) -> String {
398    let h = fnv(seed);
399    let h2 = fnv(&format!("{seed}:2"));
400    format!(
401        "{:08x}-{:04x}-{:04x}-{:04x}-{:012x}",
402        (h >> 32) as u32,
403        (h >> 16) as u16,
404        h as u16,
405        (h2 >> 48) as u16,
406        h2 & 0xffff_ffff_ffff
407    )
408}
409
410fn fnv(s: &str) -> u64 {
411    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
412    for b in s.bytes() {
413        h ^= b as u64;
414        h = h.wrapping_mul(0x0100_0000_01b3);
415    }
416    h
417}
418
419/// Whether a JSON value's type is compatible with a modelled member kind.
420pub(crate) fn kind_matches(kind: K, v: &Value) -> bool {
421    match kind {
422        K::Str | K::Blob => v.is_string(),
423        // restJson1 timestamps wire-encode as epoch-seconds JSON numbers; accept
424        // a string too so any legacy/ISO stored value still projects.
425        K::Ts => v.is_string() || v.is_number(),
426        K::Int | K::Num => v.is_number(),
427        K::Bool => v.is_boolean(),
428        K::List => v.is_array(),
429        K::Map | K::Struct => v.is_object(),
430    }
431}
432
433/// Project a stored record onto an operation's output members: keep only
434/// members present in the record whose JSON type matches the modelled kind.
435pub(crate) fn build_output(meta: &OpMeta, record: &Value) -> Value {
436    let mut out = Map::new();
437    if let Some(obj) = record.as_object() {
438        for (wire, kind) in meta.omembers {
439            if let Some(v) = obj.get(*wire) {
440                if !v.is_null() && kind_matches(*kind, v) {
441                    out.insert((*wire).to_string(), v.clone());
442                }
443            }
444        }
445    }
446    Value::Object(out)
447}
448
449/// Project a record onto a list operation's element members.
450pub(crate) fn build_element(meta: &OpMeta, record: &Value) -> Value {
451    let mut out = Map::new();
452    if let Some(obj) = record.as_object() {
453        for (wire, kind) in meta.list_elems {
454            if let Some(v) = obj.get(*wire) {
455                if !v.is_null() && kind_matches(*kind, v) {
456                    out.insert((*wire).to_string(), v.clone());
457                }
458            }
459        }
460    }
461    Value::Object(out)
462}