From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [IPv6:2a01:7e0:0:424::9]) by lore.proxmox.com (Postfix) with ESMTPS id 803CF1FF1B5 for ; Sat, 14 Feb 2026 09:10:11 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 84570146AD; Sat, 14 Feb 2026 09:10:56 +0100 (CET) From: Dietmar Maurer To: pve-devel@lists.proxmox.com Subject: [RFC proxmox v3] schema: add CommaSeparatedList wrapper type for comma-separated values Date: Sat, 14 Feb 2026 09:10:20 +0100 Message-ID: <20260214081021.2845579-1-dietmar@proxmox.com> X-Mailer: git-send-email 2.47.3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL -0.631 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment KAM_LAZY_DOMAIN_SECURITY 1 Sending domain does not have any anti-forgery methods PROLO_LEO1 0.1 Meta Catches all Leo drug variations so far RCVD_IN_VALIDITY_CERTIFIED_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to Validity was blocked. See https://knowledge.validity.com/hc/en-us/articles/20961730681243 for more information. RCVD_IN_VALIDITY_RPBL_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to Validity was blocked. See https://knowledge.validity.com/hc/en-us/articles/20961730681243 for more information. RCVD_IN_VALIDITY_SAFE_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to Validity was blocked. See https://knowledge.validity.com/hc/en-us/articles/20961730681243 for more information. RDNS_NONE 0.793 Delivered to internal network by a host with no rDNS SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_NONE 0.001 SPF: sender does not publish an SPF Record Message-ID-Hash: TQBPNMLWQJWIY6ZYLEZTMO3W7AU3JY3R X-Message-ID-Hash: TQBPNMLWQJWIY6ZYLEZTMO3W7AU3JY3R X-MailFrom: dietmar@zilli.proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox VE development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: Introduce a new CommaSeparatedList wrapper type that provides schema-aware serialization and deserialization of comma-separated values, similar to PropertyString but designed for list/array types. Key components: - CommaSeparatedListSchema trait: Provides the static ARRAY_SCHEMA required for (de)serialization (workaround for unstable generic const items in Rust) - CommaSeparatedList: A transparent Vec newtype with Deref/ DerefMut implementations for ergonomic access The wrapper automatically handles conversion between "1,2,3" string representation and Vec while validating against the element schema. Signed-off-by: Dietmar Maurer --- Changes in v3: - add module-level documentation with a usage example - remove unnecessary FromStr + Display bounds from CommaSeparatedList constructors - remove redundant AsRef/AsMut impls (already provided via Deref/DerefMut) - add a From> conversion impl. (convenience) Changes in v2: - use description from ARRAY_SCHEMA - add test for that proxmox-schema/src/comma_separated_list.rs | 214 +++++++++++++++++++++ proxmox-schema/src/lib.rs | 1 + 2 files changed, 215 insertions(+) create mode 100644 proxmox-schema/src/comma_separated_list.rs diff --git a/proxmox-schema/src/comma_separated_list.rs b/proxmox-schema/src/comma_separated_list.rs new file mode 100644 index 00000000..a8605683 --- /dev/null +++ b/proxmox-schema/src/comma_separated_list.rs @@ -0,0 +1,214 @@ +//! Comma-separated list strings. +//! +//! This module provides [`CommaSeparatedList`], a newtype wrapper around +//! `Vec` that serializes to and deserializes from a comma-separated string +//! representation (e.g. `"1,2,3"`). This is useful for API parameters that +//! accept multiple values encoded in a single string field. +//! +//! Element types must implement the [`CommaSeparatedListSchema`] trait, which +//! provides the static [`ArraySchema`](crate::ArraySchema) used for +//! validation during serialization and deserialization. +//! +//! Note that individual element values are **not quoted** in the serialized +//! string — they are simply joined with commas. This means element types must +//! serialize to simple strings that do not themselves contain commas. +//! +//! # Example +//! +//! ``` +//! use proxmox_schema::{ApiType, Schema, ArraySchema, IntegerSchema}; +//! use proxmox_schema::comma_separated_list::{CommaSeparatedList, CommaSeparatedListSchema}; +//! +//! #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +//! struct Port(u16); +//! +//! const PORT_SCHEMA: Schema = IntegerSchema::new("A network port") +//! .minimum(1) +//! .maximum(65535) +//! .schema(); +//! +//! impl ApiType for Port { +//! const API_SCHEMA: Schema = PORT_SCHEMA; +//! } +//! +//! impl CommaSeparatedListSchema for Port { +//! const ARRAY_SCHEMA: Schema = +//! ArraySchema::new("List of network ports.", &PORT_SCHEMA).schema(); +//! } +//! +//! // Deserialize from a comma-separated string: +//! let ports: CommaSeparatedList = +//! serde_json::from_value("80,443,8080".into()).unwrap(); +//! assert_eq!(ports.len(), 3); +//! +//! // Serialize back to a comma-separated string: +//! let value = serde_json::to_value(&ports).unwrap(); +//! assert_eq!(value.as_str(), Some("80,443,8080")); +//! ``` +//! +use std::ops::{Deref, DerefMut}; + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +use crate::{ApiStringFormat, ApiType, Schema, StringSchema}; + +fn serialize( + data: &[T], + serializer: S, + array_schema: &'static Schema, +) -> Result +where + S: Serializer, + T: Serialize, +{ + use serde::ser::{Error, SerializeSeq}; + + let mut ser = crate::ser::PropertyStringSerializer::new(String::new(), array_schema) + .serialize_seq(Some(data.len())) + .map_err(S::Error::custom)?; + + for element in data { + ser.serialize_element(element).map_err(S::Error::custom)?; + } + + let out = ser.end().map_err(S::Error::custom)?; + serializer.serialize_str(&out) +} + +fn deserialize<'de, D, T>(deserializer: D, array_schema: &'static Schema) -> Result +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + use serde::de::Error; + + let string = std::borrow::Cow::<'de, str>::deserialize(deserializer)?; + + T::deserialize(crate::de::SchemaDeserializer::new(string, array_schema)) + .map_err(D::Error::custom) +} + +/// Trait to provide a static array schema for a type. +/// +/// This is needed because generic const items are unstable in Rust. +pub trait CommaSeparatedListSchema: ApiType { + /// The static array schema for this type. + const ARRAY_SCHEMA: Schema; +} + +#[derive(Clone, Debug, Default, Hash, Eq, PartialEq, Ord, PartialOrd)] +#[repr(transparent)] +pub struct CommaSeparatedList(pub Vec); + +impl ApiType for CommaSeparatedList +where + T: CommaSeparatedListSchema, +{ + const API_SCHEMA: Schema = StringSchema::new(T::ARRAY_SCHEMA.unwrap_array_schema().description) + .format(&ApiStringFormat::PropertyString(&T::ARRAY_SCHEMA)) + .schema(); +} + +impl Serialize for CommaSeparatedList { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serialize(&self.0, serializer, &T::ARRAY_SCHEMA) + } +} + +impl<'de, T: CommaSeparatedListSchema + Deserialize<'de>> Deserialize<'de> + for CommaSeparatedList +{ + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let vec: Vec = deserialize(deserializer, &T::ARRAY_SCHEMA)?; + Ok(CommaSeparatedList(vec)) + } +} + +impl CommaSeparatedList { + pub fn new(inner: Vec) -> Self { + Self(inner) + } + + pub fn into_inner(self) -> Vec { + self.0 + } +} + +impl Deref for CommaSeparatedList { + type Target = Vec; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for CommaSeparatedList { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl From> for CommaSeparatedList { + fn from(inner: Vec) -> Self { + Self::new(inner) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ArraySchema, IntegerSchema}; + + // Test type that implements CommaSeparatedListSchema + #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] + struct TestNum(u32); + + const TEST_NUM_SCHEMA: Schema = IntegerSchema::new("Test number (0-3)").maximum(3).schema(); + const TEST_NUM_ARRAY_SCHEMA: Schema = + ArraySchema::new("Array of test numbers.", &TEST_NUM_SCHEMA).schema(); + + impl ApiType for TestNum { + const API_SCHEMA: Schema = TEST_NUM_SCHEMA; + } + + impl CommaSeparatedListSchema for TestNum { + const ARRAY_SCHEMA: Schema = TEST_NUM_ARRAY_SCHEMA; + } + + #[test] + fn test_comma_separated_list_serialize() { + let list = CommaSeparatedList(vec![TestNum(1), TestNum(2), TestNum(3)]); + let s = serde_json::to_value(&list).unwrap(); + // The serialize function should produce a property string + assert_eq!(s.as_str(), Some("1,2,3")); + } + + #[test] + fn test_comma_separated_list_deref() { + let list = CommaSeparatedList(vec![TestNum(42)]); + assert_eq!(list.len(), 1); + assert_eq!(list[0], TestNum(42)); + } + + #[test] + fn test_comma_separated_list_deserialize() { + let list: CommaSeparatedList = serde_json::from_value("1,2,3".into()).unwrap(); + assert_eq!(list.0, vec![TestNum(1), TestNum(2), TestNum(3)]); + // test integer maximum (4 > maximum) + let _ = serde_json::from_value::>("3,4".into()).unwrap_err(); + } + + #[test] + fn test_comma_separated_list_description() { + let descr = CommaSeparatedList::::API_SCHEMA + .unwrap_string_schema() + .description; + assert_eq!(descr, "Array of test numbers."); + } +} diff --git a/proxmox-schema/src/lib.rs b/proxmox-schema/src/lib.rs index 1647e8a9..fd773a84 100644 --- a/proxmox-schema/src/lib.rs +++ b/proxmox-schema/src/lib.rs @@ -22,6 +22,7 @@ pub mod de; pub mod format; pub mod ser; +pub mod comma_separated_list; pub mod property_string; mod schema; -- 2.47.3