all lists on lists.proxmox.com
 help / color / mirror / Atom feed
From: Dietmar Maurer <dietmar@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [pve-devel] [RFC proxmox] schema: add CommaSeparatedList<T> wrapper type for comma-separated values
Date: Wed, 28 Jan 2026 13:19:42 +0100	[thread overview]
Message-ID: <20260128121942.3495475-1-dietmar@proxmox.com> (raw)

Introduce a new CommaSeparatedList<T> 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<T>: A transparent Vec<T> newtype with Deref/
  DerefMut implementations for ergonomic access

The wrapper automatically handles conversion between "1,2,3" string
representation and Vec<T> while validating against the element schema.

Signed-off-by: Dietmar Maurer <dietmar@proxmox.com>
---
 proxmox-schema/src/comma_separated_list.rs | 165 +++++++++++++++++++++
 proxmox-schema/src/lib.rs                  |   1 +
 2 files changed, 166 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..06b85aa9
--- /dev/null
+++ b/proxmox-schema/src/comma_separated_list.rs
@@ -0,0 +1,165 @@
+use std::fmt::Display;
+use std::ops::{Deref, DerefMut};
+use std::str::FromStr;
+
+use serde::{Deserialize, Deserializer, Serialize, Serializer};
+
+use crate::{ApiStringFormat, ApiType, Schema, StringSchema};
+
+fn serialize<S, T>(
+    data: &[T],
+    serializer: S,
+    array_schema: &'static Schema,
+) -> Result<S::Ok, S::Error>
+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<T, D::Error>
+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<T>(pub Vec<T>);
+
+impl<T> ApiType for CommaSeparatedList<T>
+where
+    T: CommaSeparatedListSchema,
+{
+    const API_SCHEMA: Schema = StringSchema::new("Comma separated list")
+        .format(&ApiStringFormat::PropertyString(&T::ARRAY_SCHEMA))
+        .schema();
+}
+
+impl<T: CommaSeparatedListSchema + Serialize> Serialize for CommaSeparatedList<T> {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: Serializer,
+    {
+        serialize(&self.0, serializer, &T::ARRAY_SCHEMA)
+    }
+}
+
+impl<'de, T: CommaSeparatedListSchema + Deserialize<'de>> Deserialize<'de>
+    for CommaSeparatedList<T>
+{
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: Deserializer<'de>,
+    {
+        let vec: Vec<T> = deserialize(deserializer, &T::ARRAY_SCHEMA)?;
+        Ok(CommaSeparatedList(vec))
+    }
+}
+
+impl<T: FromStr + Display> CommaSeparatedList<T> {
+    pub fn new(inner: Vec<T>) -> Self {
+        Self(inner)
+    }
+
+    pub fn into_inner(self) -> Vec<T> {
+        self.0
+    }
+}
+
+impl<T> Deref for CommaSeparatedList<T> {
+    type Target = Vec<T>;
+
+    fn deref(&self) -> &Self::Target {
+        &self.0
+    }
+}
+
+impl<T> DerefMut for CommaSeparatedList<T> {
+    fn deref_mut(&mut self) -> &mut Self::Target {
+        &mut self.0
+    }
+}
+
+impl<T> AsRef<Vec<T>> for CommaSeparatedList<T> {
+    fn as_ref(&self) -> &Vec<T> {
+        &self.0
+    }
+}
+
+impl<T> AsMut<Vec<T>> for CommaSeparatedList<T> {
+    fn as_mut(&mut self) -> &mut Vec<T> {
+        &mut self.0
+    }
+}
+
+#[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<TestNum> = 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::<CommaSeparatedList<TestNum>>("3,4".into()).unwrap_err();
+    }
+}
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


_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel


                 reply	other threads:[~2026-01-28 12:19 UTC|newest]

Thread overview: [no followups] expand[flat|nested]  mbox.gz  Atom feed

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20260128121942.3495475-1-dietmar@proxmox.com \
    --to=dietmar@proxmox.com \
    --cc=pve-devel@lists.proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal