1use parse_rust_core::{ParseError, ParseMap, ParseValue};
4
5pub fn reject_reserved_keys(body: &ParseMap) -> Result<(), ParseError> {
16 reject_reserved_keys_in(body.keys().map(String::as_str))
17}
18
19pub fn reject_reserved_keys_in<'a>(
22 keys: impl IntoIterator<Item = &'a str>,
23) -> Result<(), ParseError> {
24 for key in keys {
25 if key.starts_with('_') {
26 return Err(ParseError::invalid_key_name(format!(
27 "Invalid field name: {key}."
28 )));
29 }
30 }
31 Ok(())
32}
33
34#[cfg(test)]
35mod tests {
36 use super::*;
37 use parse_rust_core::ParseValue;
38
39 fn m(pairs: Vec<(&str, ParseValue)>) -> ParseMap {
40 let mut map = ParseMap::new();
41 for (k, v) in pairs {
42 map.insert(k.to_string(), v);
43 }
44 map
45 }
46
47 #[test]
48 fn ordinary_fields_pass() {
49 assert!(reject_reserved_keys(&m(vec![
50 ("title", ParseValue::String("x".into())),
51 ("ACL", ParseValue::Null),
52 ]))
53 .is_ok());
54 }
55
56 #[test]
58 fn a_client_cannot_supply_a_server_internal_column() {
59 for key in [
60 "_hashed_password",
61 "_rperm",
62 "_wperm",
63 "_session_token",
64 "_perishable_token",
65 "_anything",
66 ] {
67 let e =
68 reject_reserved_keys(&m(vec![(key, ParseValue::String("x".into()))])).unwrap_err();
69 assert_eq!(e.code, parse_rust_core::ErrorCode::InvalidKeyName, "{key}");
70 assert!(e.message.contains(key));
71 }
72 }
73}
74
75pub fn strip_internal_keys(row: &mut ParseMap) {
85 row.retain(|k, _| !k.starts_with('_'));
86}
87
88const BARE_ISO_FIELDS: [&str; 3] = ["createdAt", "updatedAt", "lastUsed"];
98
99pub fn flatten_top_level_dates(row: &mut ParseMap) {
101 for key in BARE_ISO_FIELDS {
102 if let Some(ParseValue::Date(d)) = row.get(key) {
103 let iso = d.to_iso();
104 row.insert(key.to_string(), ParseValue::String(iso));
105 }
106 }
107}
108
109pub fn to_response_body(row: &ParseMap) -> ParseMap {
113 let mut out = row.clone();
114 strip_internal_keys(&mut out);
115 flatten_top_level_dates(&mut out);
116 out
117}
118
119#[cfg(test)]
120mod strip_tests {
121 use super::*;
122 use parse_rust_core::ParseValue;
123
124 #[test]
125 fn every_underscore_key_is_removed() {
126 let mut row = ParseMap::new();
127 row.insert("title".into(), ParseValue::String("x".into()));
128 row.insert("_hashed_password".into(), ParseValue::String("h".into()));
129 row.insert("_rperm".into(), ParseValue::Array(vec![]));
130 row.insert("_anything".into(), ParseValue::Null);
131 strip_internal_keys(&mut row);
132 assert_eq!(row.len(), 1);
133 assert!(row.contains_key("title"));
134 }
135
136 #[test]
137 fn the_acl_field_survives_because_it_is_not_underscore_prefixed() {
138 let mut row = ParseMap::new();
139 row.insert("ACL".into(), ParseValue::Object(ParseMap::new()));
140 strip_internal_keys(&mut row);
141 assert!(row.contains_key("ACL"), "ACL is a client-visible field");
142 }
143}
144
145#[cfg(test)]
146mod response_tests {
147 use super::*;
148 use parse_rust_core::ParseDate;
149
150 #[test]
151 fn top_level_timestamps_become_bare_strings() {
152 let mut row = ParseMap::new();
153 let d = ParseDate::parse_iso("2026-08-14T13:34:33.581Z").expect("date");
154 row.insert("createdAt".into(), ParseValue::Date(d));
155 row.insert("updatedAt".into(), ParseValue::Date(d));
156 row.insert("dueDate".into(), ParseValue::Date(d));
158
159 let out = to_response_body(&row);
160 assert!(
161 matches!(out.get("createdAt"), Some(ParseValue::String(s)) if s == "2026-08-14T13:34:33.581Z"),
162 "createdAt must be a bare ISO string at the top level"
163 );
164 assert!(matches!(out.get("updatedAt"), Some(ParseValue::String(_))));
165 assert!(
166 matches!(out.get("dueDate"), Some(ParseValue::Date(_))),
167 "a user-defined Date keeps its __type envelope"
168 );
169 }
170
171 #[test]
172 fn the_response_body_is_stripped_and_flattened_together() {
173 let mut row = ParseMap::new();
174 row.insert("_hashed_password".into(), ParseValue::String("h".into()));
175 row.insert(
176 "createdAt".into(),
177 ParseValue::Date(ParseDate::parse_iso("2026-01-01T00:00:00.000Z").expect("d")),
178 );
179 let out = to_response_body(&row);
180 assert!(out.get("_hashed_password").is_none());
181 assert!(matches!(out.get("createdAt"), Some(ParseValue::String(_))));
182 }
183}