Skip to content

Commit ad4a25c

Browse files
cursoragentlovasoa
andcommitted
feat: Support case-insensitive JSON properties
Co-authored-by: contact <[email protected]>
1 parent 94c274d commit ad4a25c

File tree

2 files changed

+78
-44
lines changed

2 files changed

+78
-44
lines changed

src/dynamic_component.rs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,9 +77,17 @@ fn expand_dynamic_stack(stack: &mut Vec<anyhow::Result<JsonValue>>) {
7777
/// if row.component == 'dynamic', return Some(row.properties), otherwise return None
7878
#[inline]
7979
fn extract_dynamic_properties(data: &mut JsonValue) -> anyhow::Result<Option<JsonValue>> {
80-
let component = data.get("component").and_then(|v| v.as_str());
80+
// Support full uppercase/lowercase property names without allocations
81+
let component = data
82+
.get("component")
83+
.or_else(|| data.get("COMPONENT"))
84+
.and_then(|v| v.as_str());
8185
if component == Some("dynamic") {
82-
let Some(properties) = data.get_mut("properties").map(JsonValue::take) else {
86+
let Some(properties) = data
87+
.get_mut("properties")
88+
.or_else(|| data.get_mut("PROPERTIES"))
89+
.map(JsonValue::take)
90+
else {
8391
anyhow::bail!(
8492
"The dynamic component requires a property named \"properties\". \
8593
Instead, it received the following: {data}"

src/render.rs

Lines changed: 68 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,8 @@ impl HeaderContext {
109109
}
110110
pub async fn handle_row(self, data: JsonValue) -> anyhow::Result<PageContext> {
111111
log::debug!("Handling header row: {data}");
112-
let comp_opt =
113-
get_object_str(&data, "component").and_then(|s| HeaderComponent::try_from(s).ok());
112+
let comp_opt = get_object_str_lower_or_upper(&data, "component", "COMPONENT")
113+
.and_then(|s| HeaderComponent::try_from(s).ok());
114114
match comp_opt {
115115
Some(HeaderComponent::StatusCode) => self.status_code(&data).map(PageContext::Header),
116116
Some(HeaderComponent::HttpHeader) => {
@@ -141,9 +141,7 @@ impl HeaderContext {
141141
}
142142

143143
fn status_code(mut self, data: &JsonValue) -> anyhow::Result<Self> {
144-
let status_code = data
145-
.as_object()
146-
.and_then(|m| m.get("status"))
144+
let status_code = get_object_value_lower_or_upper(data, "status", "STATUS")
147145
.with_context(|| "status_code component requires a status")?
148146
.as_u64()
149147
.with_context(|| "status must be a number")?;
@@ -157,7 +155,7 @@ impl HeaderContext {
157155
fn add_http_header(mut self, data: &JsonValue) -> anyhow::Result<Self> {
158156
let obj = data.as_object().with_context(|| "expected object")?;
159157
for (name, value) in obj {
160-
if name == "component" {
158+
if name.eq_ignore_ascii_case("component") {
161159
continue;
162160
}
163161
let value_str = value
@@ -173,55 +171,51 @@ impl HeaderContext {
173171
}
174172

175173
fn add_cookie(mut self, data: &JsonValue) -> anyhow::Result<Self> {
176-
let obj = data.as_object().with_context(|| "expected object")?;
177-
let name = obj
178-
.get("name")
179-
.and_then(JsonValue::as_str)
174+
data.as_object().with_context(|| "expected object")?;
175+
let name = get_object_str_lower_or_upper(data, "name", "NAME")
180176
.with_context(|| "cookie name must be a string")?;
181177
let mut cookie = actix_web::cookie::Cookie::named(name);
182178

183-
let path = obj.get("path").and_then(JsonValue::as_str);
179+
let path = get_object_str_lower_or_upper(data, "path", "PATH");
184180
if let Some(path) = path {
185181
cookie.set_path(path);
186182
} else {
187183
cookie.set_path("/");
188184
}
189-
let domain = obj.get("domain").and_then(JsonValue::as_str);
185+
let domain = get_object_str_lower_or_upper(data, "domain", "DOMAIN");
190186
if let Some(domain) = domain {
191187
cookie.set_domain(domain);
192188
}
193189

194-
let remove = obj.get("remove");
190+
let remove = get_object_value_lower_or_upper(data, "remove", "REMOVE");
195191
if remove == Some(&json!(true)) || remove == Some(&json!(1)) {
196192
cookie.make_removal();
197193
self.response.cookie(cookie);
198194
log::trace!("Removing cookie {name}");
199195
return Ok(self);
200196
}
201197

202-
let value = obj
203-
.get("value")
204-
.and_then(JsonValue::as_str)
198+
let value = get_object_str_lower_or_upper(data, "value", "VALUE")
205199
.with_context(|| "The 'value' property of the cookie component is required (unless 'remove' is set) and must be a string.")?;
206200
cookie.set_value(value);
207-
let http_only = obj.get("http_only");
201+
let http_only = get_object_value_lower_or_upper(data, "http_only", "HTTP_ONLY");
208202
cookie.set_http_only(http_only != Some(&json!(false)) && http_only != Some(&json!(0)));
209-
let same_site = obj.get("same_site").and_then(Value::as_str);
203+
let same_site = get_object_str_lower_or_upper(data, "same_site", "SAME_SITE");
210204
cookie.set_same_site(match same_site {
211205
Some("none") => actix_web::cookie::SameSite::None,
212206
Some("lax") => actix_web::cookie::SameSite::Lax,
213207
None | Some("strict") => actix_web::cookie::SameSite::Strict, // strict by default
214208
Some(other) => bail!("Cookie: invalid value for same_site: {other}"),
215209
});
216-
let secure = obj.get("secure");
210+
let secure = get_object_value_lower_or_upper(data, "secure", "SECURE");
217211
cookie.set_secure(secure != Some(&json!(false)) && secure != Some(&json!(0)));
218-
if let Some(max_age_json) = obj.get("max_age") {
212+
if let Some(max_age_json) = get_object_value_lower_or_upper(data, "max_age", "MAX_AGE") {
219213
let seconds = max_age_json
220214
.as_i64()
221215
.ok_or_else(|| anyhow::anyhow!("max_age must be a number, not {max_age_json}"))?;
222216
cookie.set_max_age(Duration::seconds(seconds));
223217
}
224-
let expires = obj.get("expires");
218+
let expires = get_object_value_lower_or_upper(data, "expires", "EXPIRES");
225219
if let Some(expires) = expires {
226220
cookie.set_expires(actix_web::cookie::Expiration::DateTime(match expires {
227221
JsonValue::String(s) => OffsetDateTime::parse(s, &Rfc3339)?,
@@ -240,7 +234,7 @@ impl HeaderContext {
240234
fn redirect(mut self, data: &JsonValue) -> anyhow::Result<HttpResponse> {
241235
self.response.status(StatusCode::FOUND);
242236
self.has_status = true;
243-
let link = get_object_str(data, "link")
237+
let link = get_object_str_lower_or_upper(data, "link", "LINK")
244238
.with_context(|| "The redirect component requires a 'link' property")?;
245239
self.response.insert_header((header::LOCATION, link));
246240
let response = self.response.body(());
@@ -251,15 +245,15 @@ impl HeaderContext {
251245
fn json(mut self, data: &JsonValue) -> anyhow::Result<PageContext> {
252246
self.response
253247
.insert_header((header::CONTENT_TYPE, "application/json"));
254-
if let Some(contents) = data.get("contents") {
248+
if let Some(contents) = get_object_value_lower_or_upper(data, "contents", "CONTENTS") {
255249
let json_response = if let Some(s) = contents.as_str() {
256250
s.as_bytes().to_owned()
257251
} else {
258252
serde_json::to_vec(contents)?
259253
};
260254
Ok(PageContext::Close(self.response.body(json_response)))
261255
} else {
262-
let body_type = get_object_str(data, "type");
256+
let body_type = get_object_str_lower_or_upper(data, "type", "TYPE");
263257
let json_renderer = match body_type {
264258
None | Some("array") => JsonBodyRenderer::new_array(self.writer),
265259
Some("jsonlines") => JsonBodyRenderer::new_jsonlines(self.writer),
@@ -284,8 +278,8 @@ impl HeaderContext {
284278
async fn csv(mut self, options: &JsonValue) -> anyhow::Result<PageContext> {
285279
self.response
286280
.insert_header((header::CONTENT_TYPE, "text/csv; charset=utf-8"));
287-
if let Some(filename) =
288-
get_object_str(options, "filename").or_else(|| get_object_str(options, "title"))
281+
if let Some(filename) = get_object_str_lower_or_upper(options, "filename", "FILENAME")
282+
.or_else(|| get_object_str_lower_or_upper(options, "title", "TITLE"))
289283
{
290284
let extension = if filename.contains('.') { "" } else { ".csv" };
291285
self.response.insert_header((
@@ -303,8 +297,8 @@ impl HeaderContext {
303297
}
304298

305299
async fn authentication(mut self, mut data: JsonValue) -> anyhow::Result<PageContext> {
306-
let password_hash = take_object_str(&mut data, "password_hash");
307-
let password = take_object_str(&mut data, "password");
300+
let password_hash = take_object_str_lower_or_upper(&mut data, "password_hash", "PASSWORD_HASH");
301+
let password = take_object_str_lower_or_upper(&mut data, "password", "PASSWORD");
308302
if let (Some(password), Some(password_hash)) = (password, password_hash) {
309303
log::debug!("Authentication with password_hash = {password_hash:?}");
310304
match verify_password_async(password_hash, password).await? {
@@ -314,7 +308,7 @@ impl HeaderContext {
314308
}
315309
log::debug!("Authentication failed");
316310
// The authentication failed
317-
let http_response: HttpResponse = if let Some(link) = get_object_str(&data, "link") {
311+
let http_response: HttpResponse = if let Some(link) = get_object_str_lower_or_upper(&data, "link", "LINK") {
318312
self.response
319313
.status(StatusCode::FOUND)
320314
.insert_header((header::LOCATION, link))
@@ -332,13 +326,13 @@ impl HeaderContext {
332326
}
333327

334328
fn download(mut self, options: &JsonValue) -> anyhow::Result<PageContext> {
335-
if let Some(filename) = get_object_str(options, "filename") {
329+
if let Some(filename) = get_object_str_lower_or_upper(options, "filename", "FILENAME") {
336330
self.response.insert_header((
337331
header::CONTENT_DISPOSITION,
338332
format!("attachment; filename=\"{filename}\""),
339333
));
340334
}
341-
let data_url = get_object_str(options, "data_url")
335+
let data_url = get_object_str_lower_or_upper(options, "data_url", "DATA_URL")
342336
.with_context(|| "The download component requires a 'data_url' property")?;
343337
let rest = data_url
344338
.strip_prefix("data:")
@@ -412,6 +406,39 @@ fn take_object_str(json: &mut JsonValue, key: &str) -> Option<String> {
412406
}
413407
}
414408

409+
#[inline]
410+
fn get_object_value_lower_or_upper<'a>(json: &'a JsonValue, lower: &str, upper: &str) -> Option<&'a JsonValue> {
411+
json.as_object()
412+
.and_then(|obj| obj.get(lower).or_else(|| obj.get(upper)))
413+
}
414+
415+
#[inline]
416+
fn get_object_str_lower_or_upper<'a>(json: &'a JsonValue, lower: &str, upper: &str) -> Option<&'a str> {
417+
get_object_value_lower_or_upper(json, lower, upper).and_then(JsonValue::as_str)
418+
}
419+
420+
#[inline]
421+
fn take_object_str_lower_or_upper(json: &mut JsonValue, lower: &str, upper: &str) -> Option<String> {
422+
if let Some(v) = json.get_mut(lower) {
423+
match v.take() {
424+
JsonValue::String(s) => return Some(s),
425+
other => {
426+
// put it back if not a string
427+
*v = other;
428+
}
429+
}
430+
}
431+
if let Some(v) = json.get_mut(upper) {
432+
match v.take() {
433+
JsonValue::String(s) => return Some(s),
434+
other => {
435+
*v = other;
436+
}
437+
}
438+
}
439+
None
440+
}
441+
415442
/**
416443
* Can receive rows, and write them in a given format to an `io::Write`
417444
*/
@@ -553,26 +580,25 @@ impl CsvBodyRenderer {
553580
options: &JsonValue,
554581
) -> anyhow::Result<CsvBodyRenderer> {
555582
let mut builder = csv_async::AsyncWriterBuilder::new();
556-
if let Some(separator) = get_object_str(options, "separator") {
583+
if let Some(separator) = get_object_str_lower_or_upper(options, "separator", "SEPARATOR") {
557584
let &[separator_byte] = separator.as_bytes() else {
558585
bail!("Invalid csv separator: {separator:?}. It must be a single byte.");
559586
};
560587
builder.delimiter(separator_byte);
561588
}
562-
if let Some(quote) = get_object_str(options, "quote") {
589+
if let Some(quote) = get_object_str_lower_or_upper(options, "quote", "QUOTE") {
563590
let &[quote_byte] = quote.as_bytes() else {
564591
bail!("Invalid csv quote: {quote:?}. It must be a single byte.");
565592
};
566593
builder.quote(quote_byte);
567594
}
568-
if let Some(escape) = get_object_str(options, "escape") {
595+
if let Some(escape) = get_object_str_lower_or_upper(options, "escape", "ESCAPE") {
569596
let &[escape_byte] = escape.as_bytes() else {
570597
bail!("Invalid csv escape: {escape:?}. It must be a single byte.");
571598
};
572599
builder.escape(escape_byte);
573600
}
574-
if options
575-
.get("bom")
601+
if get_object_value_lower_or_upper(options, "bom", "BOM")
576602
.and_then(JsonValue::as_bool)
577603
.unwrap_or(false)
578604
{
@@ -671,7 +697,7 @@ impl<W: std::io::Write> HtmlRenderContext<W> {
671697

672698
if !initial_rows
673699
.first()
674-
.and_then(|c| get_object_str(c, "component"))
700+
.and_then(|c| get_object_str_lower_or_upper(c, "component", "COMPONENT"))
675701
.is_some_and(Self::is_shell_component)
676702
{
677703
let default_shell = if request_context.is_embedded {
@@ -690,8 +716,8 @@ impl<W: std::io::Write> HtmlRenderContext<W> {
690716
let shell_row = rows_iter
691717
.next()
692718
.expect("shell row should exist at this point");
693-
let mut shell_component =
694-
get_object_str(&shell_row, "component").expect("shell should exist");
719+
let mut shell_component = get_object_str_lower_or_upper(&shell_row, "component", "COMPONENT")
720+
.expect("shell should exist");
695721
if request_context.is_embedded && shell_component != FRAGMENT_SHELL_COMPONENT {
696722
log::warn!(
697723
"Embedded pages cannot use a shell component! Ignoring the '{shell_component}' component and its properties: {shell_row}"
@@ -759,7 +785,7 @@ impl<W: std::io::Write> HtmlRenderContext<W> {
759785
}
760786

761787
pub async fn handle_row(&mut self, data: &JsonValue) -> anyhow::Result<()> {
762-
let new_component = get_object_str(data, "component");
788+
let new_component = get_object_str_lower_or_upper(data, "component", "COMPONENT");
763789
let current_component = self
764790
.current_component
765791
.as_ref()
@@ -914,15 +940,15 @@ fn handle_log_component(
914940
current_statement: Option<usize>,
915941
data: &JsonValue,
916942
) -> anyhow::Result<()> {
917-
let level_name = get_object_str(data, "level").unwrap_or("info");
943+
let level_name = get_object_str_lower_or_upper(data, "level", "LEVEL").unwrap_or("info");
918944
let log_level = log::Level::from_str(level_name).with_context(|| "Invalid log level value")?;
919945

920946
let mut target = format!("sqlpage::log from \"{}\"", source_path.display());
921947
if let Some(current_statement) = current_statement {
922948
write!(&mut target, " statement {current_statement}")?;
923949
}
924950

925-
let message = get_object_str(data, "message").context("log: missing property 'message'")?;
951+
let message = get_object_str_lower_or_upper(data, "message", "MESSAGE").context("log: missing property 'message'")?;
926952
log::log!(target: &target, log_level, "{message}");
927953
Ok(())
928954
}

0 commit comments

Comments
 (0)