From e73787ef437325c6029dc3907e60e59000765e6d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Nov 2025 01:37:10 +0000 Subject: [PATCH 1/7] Refactor: Improve run_sql variable parsing Co-authored-by: contact --- .../database/sqlpage_functions/functions.rs | 59 ++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/src/webserver/database/sqlpage_functions/functions.rs b/src/webserver/database/sqlpage_functions/functions.rs index 169ebc8b..360df87b 100644 --- a/src/webserver/database/sqlpage_functions/functions.rs +++ b/src/webserver/database/sqlpage_functions/functions.rs @@ -548,6 +548,63 @@ async fn request_method(request: &RequestInfo) -> String { request.method.to_string() } +fn parse_run_sql_variables(raw: &str) -> anyhow::Result { + let value: serde_json::Value = + serde_json::from_str(raw).with_context(|| "run_sql: unable to parse variables as JSON")?; + let object = value.as_object().ok_or_else(|| { + anyhow!( + "run_sql: the second argument must be a JSON object whose values are strings or arrays of strings. Example: {{\"name\": \"Alice\", \"store_ids\": [\"1\", \"2\"]}}" + ) + })?; + let mut parsed = ParamMap::with_capacity(object.len()); + for (key, value) in object { + let entry = match value { + serde_json::Value::String(s) => SingleOrVec::Single(s.clone()), + serde_json::Value::Array(values) => { + let mut strings = Vec::with_capacity(values.len()); + for (idx, item) in values.iter().enumerate() { + let Some(string_value) = item.as_str() else { + anyhow::bail!( + "run_sql: variable {key:?} must be an array of strings. Item at index {idx} is {item}" + ); + }; + strings.push(string_value.to_owned()); + } + SingleOrVec::Vec(strings) + } + _ => { + anyhow::bail!( + "run_sql: variable {key:?} must be a string or an array of strings, but found {value}" + ); + } + }; + parsed.insert(key.clone(), entry); + } + Ok(parsed) +} + +#[test] +fn parse_run_sql_variables_accepts_strings_and_arrays() { + let vars = + parse_run_sql_variables(r#"{"city":"Paris","ids":["1","2"]}"#).expect("valid variables"); + assert_eq!( + vars.get("city"), + Some(&SingleOrVec::Single("Paris".to_string())) + ); + assert_eq!( + vars.get("ids"), + Some(&SingleOrVec::Vec(vec!["1".to_string(), "2".to_string()])) + ); +} + +#[test] +fn parse_run_sql_variables_rejects_invalid_values() { + let err = parse_run_sql_variables(r#"{"city":1}"#).expect_err("should fail"); + let err_string = err.to_string(); + assert!(err_string.contains(r#"variable "city""#)); + assert!(err_string.contains("string or an array of strings")); +} + async fn run_sql<'a>( request: &'a RequestInfo, db_connection: &mut DbConn, @@ -571,7 +628,7 @@ async fn run_sql<'a>( .with_context(|| format!("run_sql: invalid path {sql_file_path:?}"))?; let mut tmp_req = if let Some(variables) = variables { let mut tmp_req = request.clone_without_variables(); - let variables: ParamMap = serde_json::from_str(&variables)?; + let variables = parse_run_sql_variables(&variables)?; tmp_req.get_variables = variables; tmp_req } else { From d9cf9f3c08ec144cecdd737a8e63f9f7964b3956 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Nov 2025 19:53:29 +0000 Subject: [PATCH 2/7] Refactor: Use serde_path_to_error for better error reporting Co-authored-by: contact --- Cargo.lock | 1 + Cargo.toml | 1 + .../database/sqlpage_functions/functions.rs | 69 +++---------------- src/webserver/http.rs | 58 +++++++++++++++- 4 files changed, 69 insertions(+), 60 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index de81defc..2ea58575 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4261,6 +4261,7 @@ dependencies = [ "rustls-native-certs", "serde", "serde_json", + "serde_path_to_error", "sha2", "sqlparser", "sqlx-oldapi", diff --git a/Cargo.toml b/Cargo.toml index 37fd1380..afa2ed35 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,6 +44,7 @@ tokio = { version = "1.24.1", features = ["macros", "rt", "process", "sync"] } tokio-stream = "0.1.9" anyhow = "1" serde = "1" +serde_path_to_error = "0.1" serde_json = { version = "1.0.82", features = [ "preserve_order", "raw_value", diff --git a/src/webserver/database/sqlpage_functions/functions.rs b/src/webserver/database/sqlpage_functions/functions.rs index 360df87b..b483745f 100644 --- a/src/webserver/database/sqlpage_functions/functions.rs +++ b/src/webserver/database/sqlpage_functions/functions.rs @@ -548,63 +548,6 @@ async fn request_method(request: &RequestInfo) -> String { request.method.to_string() } -fn parse_run_sql_variables(raw: &str) -> anyhow::Result { - let value: serde_json::Value = - serde_json::from_str(raw).with_context(|| "run_sql: unable to parse variables as JSON")?; - let object = value.as_object().ok_or_else(|| { - anyhow!( - "run_sql: the second argument must be a JSON object whose values are strings or arrays of strings. Example: {{\"name\": \"Alice\", \"store_ids\": [\"1\", \"2\"]}}" - ) - })?; - let mut parsed = ParamMap::with_capacity(object.len()); - for (key, value) in object { - let entry = match value { - serde_json::Value::String(s) => SingleOrVec::Single(s.clone()), - serde_json::Value::Array(values) => { - let mut strings = Vec::with_capacity(values.len()); - for (idx, item) in values.iter().enumerate() { - let Some(string_value) = item.as_str() else { - anyhow::bail!( - "run_sql: variable {key:?} must be an array of strings. Item at index {idx} is {item}" - ); - }; - strings.push(string_value.to_owned()); - } - SingleOrVec::Vec(strings) - } - _ => { - anyhow::bail!( - "run_sql: variable {key:?} must be a string or an array of strings, but found {value}" - ); - } - }; - parsed.insert(key.clone(), entry); - } - Ok(parsed) -} - -#[test] -fn parse_run_sql_variables_accepts_strings_and_arrays() { - let vars = - parse_run_sql_variables(r#"{"city":"Paris","ids":["1","2"]}"#).expect("valid variables"); - assert_eq!( - vars.get("city"), - Some(&SingleOrVec::Single("Paris".to_string())) - ); - assert_eq!( - vars.get("ids"), - Some(&SingleOrVec::Vec(vec!["1".to_string(), "2".to_string()])) - ); -} - -#[test] -fn parse_run_sql_variables_rejects_invalid_values() { - let err = parse_run_sql_variables(r#"{"city":1}"#).expect_err("should fail"); - let err_string = err.to_string(); - assert!(err_string.contains(r#"variable "city""#)); - assert!(err_string.contains("string or an array of strings")); -} - async fn run_sql<'a>( request: &'a RequestInfo, db_connection: &mut DbConn, @@ -628,7 +571,17 @@ async fn run_sql<'a>( .with_context(|| format!("run_sql: invalid path {sql_file_path:?}"))?; let mut tmp_req = if let Some(variables) = variables { let mut tmp_req = request.clone_without_variables(); - let variables = parse_run_sql_variables(&variables)?; + let mut deserializer = serde_json::Deserializer::from_str(&variables); + let variables: ParamMap = + serde_path_to_error::deserialize(&mut deserializer).map_err(|err| { + let path = err.path().to_string(); + let context = if path.is_empty() { + "run_sql: unable to parse the variables argument".to_string() + } else { + format!("run_sql: invalid value for the variables argument at {path}") + }; + anyhow::Error::new(err.into_inner()).context(context) + })?; tmp_req.get_variables = variables; tmp_req } else { diff --git a/src/webserver/http.rs b/src/webserver/http.rs index dc63d49d..e32c0b6a 100644 --- a/src/webserver/http.rs +++ b/src/webserver/http.rs @@ -226,13 +226,41 @@ async fn render_sql( resp_recv.await.map_err(ErrorInternalServerError) } -#[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq, Clone)] -#[serde(untagged)] +#[derive(Debug, serde::Serialize, PartialEq, Clone)] pub enum SingleOrVec { Single(String), Vec(Vec), } +impl<'de> serde::Deserialize<'de> for SingleOrVec { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = serde_json::Value::deserialize(deserializer)?; + match value { + serde_json::Value::String(s) => Ok(SingleOrVec::Single(s)), + serde_json::Value::Array(values) => { + let mut strings = Vec::with_capacity(values.len()); + for (idx, item) in values.into_iter().enumerate() { + match item { + serde_json::Value::String(s) => strings.push(s), + other => { + return Err(D::Error::custom(format!( + "expected an array of strings, but item at index {idx} is {other}" + ))) + } + } + } + Ok(SingleOrVec::Vec(strings)) + } + other => Err(D::Error::custom(format!( + "expected a string or an array of strings, but found {other}" + ))), + } + } +} + impl std::fmt::Display for SingleOrVec { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -263,6 +291,7 @@ impl SingleOrVec { } } } + fn take_vec(&mut self) -> Vec { match self { SingleOrVec::Single(x) => vec![mem::take(x)], @@ -279,6 +308,31 @@ impl SingleOrVec { } } +#[cfg(test)] +mod single_or_vec_tests { + use super::SingleOrVec; + + #[test] + fn deserializes_string_and_array_values() { + let single: SingleOrVec = serde_json::from_str(r#""hello""#).unwrap(); + assert_eq!(single, SingleOrVec::Single("hello".to_string())); + let array: SingleOrVec = serde_json::from_str(r#"["a","b"]"#).unwrap(); + assert_eq!( + array, + SingleOrVec::Vec(vec!["a".to_string(), "b".to_string()]) + ); + } + + #[test] + fn rejects_non_string_items() { + let err = serde_json::from_str::(r#"["a", 1]"#).unwrap_err(); + assert!( + err.to_string() + .contains("expected an array of strings, but item at index 1 is 1"), + "{err}" + ); + } +} async fn process_sql_request( req: &mut ServiceRequest, sql_path: PathBuf, From a20a39c3d505e2fbf590625c93f84a47cb002ba7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Nov 2025 22:01:34 +0000 Subject: [PATCH 3/7] Remove serde_path_to_error, use serde_json error reporting Co-authored-by: contact --- Cargo.lock | 1 - Cargo.toml | 1 - .../database/sqlpage_functions/functions.rs | 19 ++++++++----------- src/webserver/http.rs | 1 + 4 files changed, 9 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2ea58575..de81defc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4261,7 +4261,6 @@ dependencies = [ "rustls-native-certs", "serde", "serde_json", - "serde_path_to_error", "sha2", "sqlparser", "sqlx-oldapi", diff --git a/Cargo.toml b/Cargo.toml index afa2ed35..37fd1380 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,7 +44,6 @@ tokio = { version = "1.24.1", features = ["macros", "rt", "process", "sync"] } tokio-stream = "0.1.9" anyhow = "1" serde = "1" -serde_path_to_error = "0.1" serde_json = { version = "1.0.82", features = [ "preserve_order", "raw_value", diff --git a/src/webserver/database/sqlpage_functions/functions.rs b/src/webserver/database/sqlpage_functions/functions.rs index b483745f..a0761a63 100644 --- a/src/webserver/database/sqlpage_functions/functions.rs +++ b/src/webserver/database/sqlpage_functions/functions.rs @@ -571,17 +571,14 @@ async fn run_sql<'a>( .with_context(|| format!("run_sql: invalid path {sql_file_path:?}"))?; let mut tmp_req = if let Some(variables) = variables { let mut tmp_req = request.clone_without_variables(); - let mut deserializer = serde_json::Deserializer::from_str(&variables); - let variables: ParamMap = - serde_path_to_error::deserialize(&mut deserializer).map_err(|err| { - let path = err.path().to_string(); - let context = if path.is_empty() { - "run_sql: unable to parse the variables argument".to_string() - } else { - format!("run_sql: invalid value for the variables argument at {path}") - }; - anyhow::Error::new(err.into_inner()).context(context) - })?; + let variables: ParamMap = serde_json::from_str(&variables).map_err(|err| { + let context = format!( + "run_sql: unable to parse the variables argument (line {}, column {})", + err.line(), + err.column() + ); + anyhow::Error::new(err).context(context) + })?; tmp_req.get_variables = variables; tmp_req } else { diff --git a/src/webserver/http.rs b/src/webserver/http.rs index e32c0b6a..5d1df79b 100644 --- a/src/webserver/http.rs +++ b/src/webserver/http.rs @@ -34,6 +34,7 @@ use anyhow::{bail, Context}; use chrono::{DateTime, Utc}; use futures_util::stream::Stream; use futures_util::StreamExt; +use serde::de::Error as _; use std::borrow::Cow; use std::mem; use std::path::PathBuf; From 9a7f0d75b32e84fba3db10a6a33b27cac97c0860 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Nov 2025 23:09:29 +0000 Subject: [PATCH 4/7] Refactor: Move SingleOrVec tests to end of file Co-authored-by: contact --- src/webserver/http.rs | 51 ++++++++++++++++++++++--------------------- 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/src/webserver/http.rs b/src/webserver/http.rs index 5d1df79b..fcb29e06 100644 --- a/src/webserver/http.rs +++ b/src/webserver/http.rs @@ -309,31 +309,6 @@ impl SingleOrVec { } } -#[cfg(test)] -mod single_or_vec_tests { - use super::SingleOrVec; - - #[test] - fn deserializes_string_and_array_values() { - let single: SingleOrVec = serde_json::from_str(r#""hello""#).unwrap(); - assert_eq!(single, SingleOrVec::Single("hello".to_string())); - let array: SingleOrVec = serde_json::from_str(r#"["a","b"]"#).unwrap(); - assert_eq!( - array, - SingleOrVec::Vec(vec!["a".to_string(), "b".to_string()]) - ); - } - - #[test] - fn rejects_non_string_items() { - let err = serde_json::from_str::(r#"["a", 1]"#).unwrap_err(); - assert!( - err.to_string() - .contains("expected an array of strings, but item at index 1 is 1"), - "{err}" - ); - } -} async fn process_sql_request( req: &mut ServiceRequest, sql_path: PathBuf, @@ -648,3 +623,29 @@ fn bind_unix_socket_err(e: std::io::Error, unix_socket: &std::path::Path) -> any }; anyhow::anyhow!(e).context(ctx) } + +#[cfg(test)] +mod single_or_vec_tests { + use super::SingleOrVec; + + #[test] + fn deserializes_string_and_array_values() { + let single: SingleOrVec = serde_json::from_str(r#""hello""#).unwrap(); + assert_eq!(single, SingleOrVec::Single("hello".to_string())); + let array: SingleOrVec = serde_json::from_str(r#"["a","b"]"#).unwrap(); + assert_eq!( + array, + SingleOrVec::Vec(vec!["a".to_string(), "b".to_string()]) + ); + } + + #[test] + fn rejects_non_string_items() { + let err = serde_json::from_str::(r#"["a", 1]"#).unwrap_err(); + assert!( + err.to_string() + .contains("expected an array of strings, but item at index 1 is 1"), + "{err}" + ); + } +} From 790f729471395e62777f02124835f381843ba163 Mon Sep 17 00:00:00 2001 From: lovasoa Date: Wed, 19 Nov 2025 01:29:29 +0100 Subject: [PATCH 5/7] fix SingleOrVec serialization --- src/webserver/http.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/webserver/http.rs b/src/webserver/http.rs index fcb29e06..a8f40a29 100644 --- a/src/webserver/http.rs +++ b/src/webserver/http.rs @@ -228,6 +228,7 @@ async fn render_sql( } #[derive(Debug, serde::Serialize, PartialEq, Clone)] +#[serde(untagged)] pub enum SingleOrVec { Single(String), Vec(Vec), From 72e8ac8679c239963efb877ce455102bc71fcc09 Mon Sep 17 00:00:00 2001 From: lovasoa Date: Wed, 19 Nov 2025 01:33:23 +0100 Subject: [PATCH 6/7] Refactor: Move SingleOrVec to a dedicated module and update imports --- src/webserver/database/execute_queries.rs | 2 +- .../database/sqlpage_functions/functions.rs | 2 +- src/webserver/database/syntax_tree.rs | 2 +- src/webserver/http.rs | 86 ------------------- src/webserver/http_request_info.rs | 2 +- src/webserver/mod.rs | 1 + src/webserver/request_variables.rs | 2 +- src/webserver/single_or_vec.rs | 86 +++++++++++++++++++ 8 files changed, 92 insertions(+), 91 deletions(-) create mode 100644 src/webserver/single_or_vec.rs diff --git a/src/webserver/database/execute_queries.rs b/src/webserver/database/execute_queries.rs index b831539b..8b31a436 100644 --- a/src/webserver/database/execute_queries.rs +++ b/src/webserver/database/execute_queries.rs @@ -15,8 +15,8 @@ use super::sql::{ use crate::dynamic_component::parse_dynamic_rows; use crate::utils::add_value_to_map; use crate::webserver::database::sql_to_json::row_to_string; -use crate::webserver::http::SingleOrVec; use crate::webserver::http_request_info::RequestInfo; +use crate::webserver::single_or_vec::SingleOrVec; use super::syntax_tree::{extract_req_param, StmtParam}; use super::{error_highlighting::display_db_error, Database, DbItem}; diff --git a/src/webserver/database/sqlpage_functions/functions.rs b/src/webserver/database/sqlpage_functions/functions.rs index a0761a63..b7665076 100644 --- a/src/webserver/database/sqlpage_functions/functions.rs +++ b/src/webserver/database/sqlpage_functions/functions.rs @@ -4,9 +4,9 @@ use crate::webserver::{ blob_to_data_url::vec_to_data_uri_with_mime, execute_queries::DbConn, sqlpage_functions::url_parameter_deserializer::URLParameters, }, - http::SingleOrVec, http_client::make_http_client, request_variables::ParamMap, + single_or_vec::SingleOrVec, ErrorWithStatus, }; use anyhow::{anyhow, Context}; diff --git a/src/webserver/database/syntax_tree.rs b/src/webserver/database/syntax_tree.rs index 1558912d..b63aa738 100644 --- a/src/webserver/database/syntax_tree.rs +++ b/src/webserver/database/syntax_tree.rs @@ -16,8 +16,8 @@ use std::str::FromStr; use sqlparser::ast::FunctionArg; -use crate::webserver::http::SingleOrVec; use crate::webserver::http_request_info::RequestInfo; +use crate::webserver::single_or_vec::SingleOrVec; use super::{ execute_queries::DbConn, sql::function_args_to_stmt_params, diff --git a/src/webserver/http.rs b/src/webserver/http.rs index a8f40a29..d37c8e3f 100644 --- a/src/webserver/http.rs +++ b/src/webserver/http.rs @@ -34,9 +34,6 @@ use anyhow::{bail, Context}; use chrono::{DateTime, Utc}; use futures_util::stream::Stream; use futures_util::StreamExt; -use serde::de::Error as _; -use std::borrow::Cow; -use std::mem; use std::path::PathBuf; use std::pin::Pin; use std::sync::Arc; @@ -227,89 +224,6 @@ async fn render_sql( resp_recv.await.map_err(ErrorInternalServerError) } -#[derive(Debug, serde::Serialize, PartialEq, Clone)] -#[serde(untagged)] -pub enum SingleOrVec { - Single(String), - Vec(Vec), -} - -impl<'de> serde::Deserialize<'de> for SingleOrVec { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let value = serde_json::Value::deserialize(deserializer)?; - match value { - serde_json::Value::String(s) => Ok(SingleOrVec::Single(s)), - serde_json::Value::Array(values) => { - let mut strings = Vec::with_capacity(values.len()); - for (idx, item) in values.into_iter().enumerate() { - match item { - serde_json::Value::String(s) => strings.push(s), - other => { - return Err(D::Error::custom(format!( - "expected an array of strings, but item at index {idx} is {other}" - ))) - } - } - } - Ok(SingleOrVec::Vec(strings)) - } - other => Err(D::Error::custom(format!( - "expected a string or an array of strings, but found {other}" - ))), - } - } -} - -impl std::fmt::Display for SingleOrVec { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - SingleOrVec::Single(x) => write!(f, "{x}"), - SingleOrVec::Vec(v) => { - write!(f, "[")?; - let mut it = v.iter(); - if let Some(first) = it.next() { - write!(f, "{first}")?; - } - for item in it { - write!(f, ", {item}")?; - } - write!(f, "]") - } - } - } -} - -impl SingleOrVec { - pub(crate) fn merge(&mut self, other: Self) { - match (self, other) { - (Self::Single(old), Self::Single(new)) => *old = new, - (old, mut new) => { - let mut v = old.take_vec(); - v.extend_from_slice(&new.take_vec()); - *old = Self::Vec(v); - } - } - } - - fn take_vec(&mut self) -> Vec { - match self { - SingleOrVec::Single(x) => vec![mem::take(x)], - SingleOrVec::Vec(v) => mem::take(v), - } - } - - #[must_use] - pub fn as_json_str(&self) -> Cow<'_, str> { - match self { - SingleOrVec::Single(x) => Cow::Borrowed(x), - SingleOrVec::Vec(v) => Cow::Owned(serde_json::to_string(v).unwrap()), - } - } -} - async fn process_sql_request( req: &mut ServiceRequest, sql_path: PathBuf, diff --git a/src/webserver/http_request_info.rs b/src/webserver/http_request_info.rs index ff0f3114..8f9cbac1 100644 --- a/src/webserver/http_request_info.rs +++ b/src/webserver/http_request_info.rs @@ -278,8 +278,8 @@ async fn is_file_field_empty( #[cfg(test)] mod test { - use super::super::http::SingleOrVec; use super::*; + use crate::webserver::single_or_vec::SingleOrVec; use crate::{app_config::AppConfig, webserver::server_timing::ServerTiming}; use actix_web::{http::header::ContentType, test::TestRequest}; diff --git a/src/webserver/mod.rs b/src/webserver/mod.rs index 484ad40d..4a70d2a1 100644 --- a/src/webserver/mod.rs +++ b/src/webserver/mod.rs @@ -48,4 +48,5 @@ pub use database::migrations::apply; pub mod oidc; pub mod response_writer; pub mod routing; +mod single_or_vec; mod static_content; diff --git a/src/webserver/request_variables.rs b/src/webserver/request_variables.rs index 0aa9879a..245d8a0d 100644 --- a/src/webserver/request_variables.rs +++ b/src/webserver/request_variables.rs @@ -1,6 +1,6 @@ use std::collections::{hash_map::Entry, HashMap}; -use super::http::SingleOrVec; +use crate::webserver::single_or_vec::SingleOrVec; pub type ParamMap = HashMap; diff --git a/src/webserver/single_or_vec.rs b/src/webserver/single_or_vec.rs new file mode 100644 index 00000000..854bb56e --- /dev/null +++ b/src/webserver/single_or_vec.rs @@ -0,0 +1,86 @@ +use serde::de::Error; +use std::borrow::Cow; +use std::mem; + +#[derive(Debug, serde::Serialize, PartialEq, Clone)] +#[serde(untagged)] +pub enum SingleOrVec { + Single(String), + Vec(Vec), +} + +impl<'de> serde::Deserialize<'de> for SingleOrVec { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = serde_json::Value::deserialize(deserializer)?; + match value { + serde_json::Value::String(s) => Ok(SingleOrVec::Single(s)), + serde_json::Value::Array(values) => { + let mut strings = Vec::with_capacity(values.len()); + for (idx, item) in values.into_iter().enumerate() { + match item { + serde_json::Value::String(s) => strings.push(s), + other => { + return Err(D::Error::custom(format!( + "expected an array of strings, but item at index {idx} is {other}" + ))) + } + } + } + Ok(SingleOrVec::Vec(strings)) + } + other => Err(D::Error::custom(format!( + "expected a string or an array of strings, but found {other}" + ))), + } + } +} + +impl std::fmt::Display for SingleOrVec { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SingleOrVec::Single(x) => write!(f, "{x}"), + SingleOrVec::Vec(v) => { + write!(f, "[")?; + let mut it = v.iter(); + if let Some(first) = it.next() { + write!(f, "{first}")?; + } + for item in it { + write!(f, ", {item}")?; + } + write!(f, "]") + } + } + } +} + +impl SingleOrVec { + pub(crate) fn merge(&mut self, other: Self) { + match (self, other) { + (Self::Single(old), Self::Single(new)) => *old = new, + (old, mut new) => { + let mut v = old.take_vec(); + v.extend_from_slice(&new.take_vec()); + *old = Self::Vec(v); + } + } + } + + fn take_vec(&mut self) -> Vec { + match self { + SingleOrVec::Single(x) => vec![mem::take(x)], + SingleOrVec::Vec(v) => mem::take(v), + } + } + + #[must_use] + pub fn as_json_str(&self) -> Cow<'_, str> { + match self { + SingleOrVec::Single(x) => Cow::Borrowed(x), + SingleOrVec::Vec(v) => Cow::Owned(serde_json::to_string(v).unwrap()), + } + } +} From 90f0bd3840101cc20d4bb7bc3fdfa77c9c2f5aa9 Mon Sep 17 00:00:00 2001 From: lovasoa Date: Wed, 19 Nov 2025 01:39:52 +0100 Subject: [PATCH 7/7] Move SingleOrVec tests from http.rs to single_or_vec.rs for better organization --- src/webserver/http.rs | 26 ----------------------- src/webserver/single_or_vec.rs | 38 ++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 26 deletions(-) diff --git a/src/webserver/http.rs b/src/webserver/http.rs index d37c8e3f..9468e11e 100644 --- a/src/webserver/http.rs +++ b/src/webserver/http.rs @@ -538,29 +538,3 @@ fn bind_unix_socket_err(e: std::io::Error, unix_socket: &std::path::Path) -> any }; anyhow::anyhow!(e).context(ctx) } - -#[cfg(test)] -mod single_or_vec_tests { - use super::SingleOrVec; - - #[test] - fn deserializes_string_and_array_values() { - let single: SingleOrVec = serde_json::from_str(r#""hello""#).unwrap(); - assert_eq!(single, SingleOrVec::Single("hello".to_string())); - let array: SingleOrVec = serde_json::from_str(r#"["a","b"]"#).unwrap(); - assert_eq!( - array, - SingleOrVec::Vec(vec!["a".to_string(), "b".to_string()]) - ); - } - - #[test] - fn rejects_non_string_items() { - let err = serde_json::from_str::(r#"["a", 1]"#).unwrap_err(); - assert!( - err.to_string() - .contains("expected an array of strings, but item at index 1 is 1"), - "{err}" - ); - } -} diff --git a/src/webserver/single_or_vec.rs b/src/webserver/single_or_vec.rs index 854bb56e..e5438678 100644 --- a/src/webserver/single_or_vec.rs +++ b/src/webserver/single_or_vec.rs @@ -84,3 +84,41 @@ impl SingleOrVec { } } } + +#[cfg(test)] +mod single_or_vec_tests { + use super::SingleOrVec; + + #[test] + fn deserializes_string_and_array_values() { + let single: SingleOrVec = serde_json::from_str(r#""hello""#).unwrap(); + assert_eq!(single, SingleOrVec::Single("hello".to_string())); + let array: SingleOrVec = serde_json::from_str(r#"["a","b"]"#).unwrap(); + assert_eq!( + array, + SingleOrVec::Vec(vec!["a".to_string(), "b".to_string()]) + ); + } + + #[test] + fn rejects_non_string_items() { + let err = serde_json::from_str::(r#"["a", 1]"#).unwrap_err(); + assert!( + err.to_string() + .contains("expected an array of strings, but item at index 1 is 1"), + "{err}" + ); + } + + #[test] + fn displays_single_value() { + let single = SingleOrVec::Single("hello".to_string()); + assert_eq!(single.to_string(), "hello"); + } + + #[test] + fn displays_array_values() { + let array = SingleOrVec::Vec(vec!["a".to_string(), "b".to_string()]); + assert_eq!(array.to_string(), "[a, b]"); + } +}