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