rovenue/placements/
fallback.rs1use 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#[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
36pub 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 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}