rovenue/placements/
fallback.rs

1use std::collections::HashMap;
2
3use serde::Deserialize;
4use serde_json::Value;
5
6use crate::error::{ErrorKind, RovenueError, RovenueResult};
7use crate::logging::{LogLevel, Logger};
8
9use super::types::PlacementsResponse;
10
11/// Spec D1 bundled fallback-file format, produced by the dashboard's
12/// `GET /dashboard/projects/:id/placements/fallback-export` (commit 792cd94c):
13/// `{ formatVersion: 1, generatedAt, projectId, placements: { [identifier]:
14/// <PlacementsResponse-shaped data> } }`. `generatedAt`/`projectId` are not
15/// validated or used by the core — they exist for humans inspecting the
16/// file — so they're captured loosely as `Value` rather than typed fields
17/// that could reject a future-compatible file over an unrelated shape drift.
18#[derive(Debug, Deserialize)]
19struct FallbackFileWire {
20    #[serde(rename = "formatVersion")]
21    format_version: Value,
22    #[serde(default)]
23    placements: HashMap<String, Value>,
24}
25
26fn invalid_argument(message: String) -> RovenueError {
27    RovenueError {
28        kind: ErrorKind::InvalidArgument,
29        message,
30        server_code: None,
31        http_status: None,
32        retryable: false,
33    }
34}
35
36/// Parse a spec D1 fallback-placements file into `identifier -> raw
37/// PlacementsResponse JSON string`.
38///
39/// Storage choice: `PlacementsResponse` isn't (and shouldn't become)
40/// `Clone` — and the crate's established convention for "hold a wire
41/// payload in memory, decode on demand" is already raw-JSON-string storage
42/// (see `PlacementsCacheRepo`/the offline-cache branch in `client.rs`,
43/// which re-`serde_json::from_str`s the cached string on every offline
44/// resolve). This function follows the same convention rather than
45/// requiring `PlacementsResponse: Clone` — one fewer derive to keep in
46/// sync across the wire-type tree, and `PlacementsClient::get_funnel`
47/// already has the decode-from-string codepath it needs to reuse.
48///
49/// Fails loudly — a distinct `InvalidArgument` error, not a silent partial
50/// result — when the file doesn't parse as JSON at all, or when
51/// `formatVersion` isn't the literal integer `1`. An integrator who bundles
52/// a stale/mismatched export must see this at `configure`/`setFallbackPlacements`
53/// time, not months later when a device finally goes offline.
54///
55/// An individual `placements` entry that doesn't decode as `PlacementsResponse`
56/// is SKIPPED (logged when a logger is attached, never fatal) — a
57/// partially-valid file still helps: one bad entry shouldn't sacrifice every
58/// other placement's offline coverage.
59pub fn parse_fallback_file(
60    json: &str,
61    logger: Option<&Logger>,
62) -> RovenueResult<HashMap<String, String>> {
63    let file: FallbackFileWire = serde_json::from_str(json).map_err(|e| {
64        invalid_argument(format!("fallback placements file is not valid JSON: {e}"))
65    })?;
66
67    if file.format_version.as_i64() != Some(1) {
68        return Err(invalid_argument(format!(
69            "unsupported fallback placements formatVersion {} (expected 1)",
70            file.format_version
71        )));
72    }
73
74    let mut out = HashMap::with_capacity(file.placements.len());
75    for (identifier, raw) in file.placements {
76        match serde_json::from_value::<PlacementsResponse>(raw.clone()) {
77            Ok(_) => match serde_json::to_string(&raw) {
78                Ok(s) => {
79                    out.insert(identifier, s);
80                }
81                Err(_) => {
82                    // Value -> String re-encoding failure is not realistically
83                    // reachable (raw came from a successful JSON parse), but
84                    // treat it the same as a decode failure rather than panic.
85                    log_skip(logger, &identifier, "re-serialization failed");
86                }
87            },
88            Err(e) => log_skip(logger, &identifier, &e.to_string()),
89        }
90    }
91    Ok(out)
92}
93
94fn log_skip(logger: Option<&Logger>, identifier: &str, reason: &str) {
95    if let Some(logger) = logger {
96        let identifier = identifier.to_string();
97        let reason = reason.to_string();
98        logger.log(
99            LogLevel::Warn,
100            move || format!("fallback placement entry skipped: {identifier}"),
101            move || {
102                let mut f = HashMap::new();
103                f.insert("op".to_string(), "fallback_placements_parse".to_string());
104                f.insert("reason".to_string(), reason.clone());
105                f
106            },
107        );
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    const VALID_ENTRY: &str = r#"{"placement":{"identifier":"onboarding","revision":9},"funnel":{"identifier":"fallback_funnel","id":"fnl_fallback","revision":1,"configFormatVersion":1,"config":{},"offering":null},"experiment":null}"#;
116
117    #[test]
118    fn rejects_non_json() {
119        let err = parse_fallback_file("not json", None).unwrap_err();
120        assert_eq!(err.kind, ErrorKind::InvalidArgument);
121    }
122
123    #[test]
124    fn rejects_wrong_format_version_with_distinct_message() {
125        let json = format!(
126            r#"{{"formatVersion":2,"generatedAt":1,"projectId":"p","placements":{{"onboarding":{VALID_ENTRY}}}}}"#
127        );
128        let err = parse_fallback_file(&json, None).unwrap_err();
129        assert_eq!(err.kind, ErrorKind::InvalidArgument);
130        assert!(
131            err.message.contains("formatVersion"),
132            "expected a formatVersion-specific message, got: {}",
133            err.message
134        );
135    }
136
137    #[test]
138    fn rejects_missing_format_version() {
139        let json = r#"{"generatedAt":1,"projectId":"p","placements":{}}"#;
140        let err = parse_fallback_file(json, None).unwrap_err();
141        assert_eq!(err.kind, ErrorKind::InvalidArgument);
142    }
143
144    #[test]
145    fn loads_all_valid_entries() {
146        let json = format!(
147            r#"{{"formatVersion":1,"generatedAt":1,"projectId":"p","placements":{{"a":{VALID_ENTRY},"b":{VALID_ENTRY}}}}}"#
148        );
149        let loaded = parse_fallback_file(&json, None).unwrap();
150        assert_eq!(loaded.len(), 2);
151        assert!(loaded.contains_key("a"));
152        assert!(loaded.contains_key("b"));
153    }
154
155    #[test]
156    fn partially_valid_file_loads_n_minus_1_and_skips_bad_entry() {
157        let json = format!(
158            r#"{{"formatVersion":1,"generatedAt":1,"projectId":"p","placements":{{"good":{VALID_ENTRY},"bad":{{"placement":"not-an-object"}}}}}}"#
159        );
160        let loaded = parse_fallback_file(&json, None).unwrap();
161        assert_eq!(loaded.len(), 1, "expected only the valid entry to load");
162        assert!(loaded.contains_key("good"));
163        assert!(!loaded.contains_key("bad"));
164    }
165
166    #[test]
167    fn loaded_entry_decodes_back_into_placements_response() {
168        let json = format!(
169            r#"{{"formatVersion":1,"generatedAt":1,"projectId":"p","placements":{{"onboarding":{VALID_ENTRY}}}}}"#
170        );
171        let loaded = parse_fallback_file(&json, None).unwrap();
172        let raw = loaded.get("onboarding").unwrap();
173        let decoded: PlacementsResponse = serde_json::from_str(raw).unwrap();
174        assert_eq!(decoded.placement.unwrap().identifier, "onboarding");
175        assert_eq!(decoded.funnel.unwrap().identifier, "fallback_funnel");
176    }
177}