Skip to main content

origincloud_core/
protobuf.rs

1//! The well-known types (`google.protobuf`), with their proto3 JSON
2//! mapping: [`FieldMask`], [`Timestamp`], [`Duration`], [`Empty`] and the
3//! others.
4//!
5//! They are [pbjson-types](https://docs.rs/pbjson-types)', except for
6//! three whose JSON form there isn't quite proto3's: [`FieldMask`], written
7//! as one string of camel case paths, [`Timestamp`], written in UTC with a
8//! `Z`, and [`Any`], whose message's fields sit next to its `@type`.
9
10pub use pbjson_types::*;
11
12pub use crate::rpc::Any;
13
14use chrono::{DateTime, SecondsFormat, Utc};
15use serde::de::Error as _;
16use serde::ser::Error as _;
17use serde::{Deserialize, Deserializer, Serialize, Serializer};
18
19/// A point in time (`google.protobuf.Timestamp`): seconds and nanoseconds
20/// since 1970-01-01T00:00:00Z. In JSON it is RFC 3339 in UTC, such as
21/// `"2026-10-30T12:00:00.500Z"`, with 0, 3, 6 or 9 fractional digits.
22#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Message)]
23pub struct Timestamp {
24    /// Seconds since the Unix epoch.
25    #[prost(int64, tag = "1")]
26    pub seconds: i64,
27    /// Nanoseconds of the second, from 0 to 999,999,999.
28    #[prost(int32, tag = "2")]
29    pub nanos: i32,
30}
31
32impl From<DateTime<Utc>> for Timestamp {
33    fn from(t: DateTime<Utc>) -> Self {
34        Self {
35            seconds: t.timestamp(),
36            nanos: i32::try_from(t.timestamp_subsec_nanos()).unwrap_or(999_999_999),
37        }
38    }
39}
40
41impl From<std::time::SystemTime> for Timestamp {
42    fn from(t: std::time::SystemTime) -> Self {
43        DateTime::<Utc>::from(t).into()
44    }
45}
46
47impl TryFrom<Timestamp> for DateTime<Utc> {
48    type Error = String;
49
50    fn try_from(t: Timestamp) -> Result<Self, String> {
51        u32::try_from(t.nanos)
52            .ok()
53            .and_then(|nanos| DateTime::from_timestamp(t.seconds, nanos))
54            .ok_or_else(|| {
55                format!(
56                    "{}s {}ns is outside the times a timestamp holds",
57                    t.seconds, t.nanos
58                )
59            })
60    }
61}
62
63impl Serialize for Timestamp {
64    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
65        let t = DateTime::<Utc>::try_from(*self).map_err(S::Error::custom)?;
66        serializer.serialize_str(&t.to_rfc3339_opts(SecondsFormat::AutoSi, true))
67    }
68}
69
70impl<'de> Deserialize<'de> for Timestamp {
71    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
72        let text = String::deserialize(deserializer)?;
73        let t = DateTime::parse_from_rfc3339(&text).map_err(D::Error::custom)?;
74        Ok(t.with_timezone(&Utc).into())
75    }
76}
77
78/// A set of field paths (`google.protobuf.FieldMask`), such as the fields
79/// an update changes. The paths are the proto field names, such as
80/// `size_gib`; in JSON they are one string of camel case paths, such as
81/// `"sizeGib,tags"`.
82#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
83pub struct FieldMask {
84    /// The field paths, such as `size_gib` or `volume.size_gib`.
85    #[prost(string, repeated, tag = "1")]
86    pub paths: Vec<String>,
87}
88
89impl FieldMask {
90    /// A mask of `paths`.
91    pub fn new<I, S>(paths: I) -> Self
92    where
93        I: IntoIterator<Item = S>,
94        S: Into<String>,
95    {
96        Self {
97            paths: paths.into_iter().map(Into::into).collect(),
98        }
99    }
100}
101
102impl Serialize for FieldMask {
103    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
104        let mut joined = String::new();
105        for (i, path) in self.paths.iter().enumerate() {
106            if i > 0 {
107                joined.push(',');
108            }
109            for (j, part) in path.split('.').enumerate() {
110                if j > 0 {
111                    joined.push('.');
112                }
113                joined.push_str(&to_lower_camel(part));
114            }
115        }
116        serializer.serialize_str(&joined)
117    }
118}
119
120impl<'de> Deserialize<'de> for FieldMask {
121    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
122        let joined = String::deserialize(deserializer)?;
123        let mut paths = Vec::new();
124        for path in joined.split(',').filter(|p| !p.is_empty()) {
125            if path.contains('_') {
126                return Err(D::Error::custom(format!(
127                    "the field mask path {path:?} isn't in camel case"
128                )));
129            }
130            let parts: Vec<String> = path.split('.').map(to_snake).collect();
131            paths.push(parts.join("."));
132        }
133        Ok(Self { paths })
134    }
135}
136
137fn to_lower_camel(name: &str) -> String {
138    let mut out = String::with_capacity(name.len());
139    let mut upper = false;
140    for c in name.chars() {
141        if c == '_' {
142            upper = true;
143        } else if upper {
144            out.extend(c.to_uppercase());
145            upper = false;
146        } else {
147            out.push(c);
148        }
149    }
150    out
151}
152
153fn to_snake(name: &str) -> String {
154    let mut out = String::with_capacity(name.len() + 4);
155    for c in name.chars() {
156        if c.is_ascii_uppercase() {
157            out.push('_');
158            out.push(c.to_ascii_lowercase());
159        } else {
160            out.push(c);
161        }
162    }
163    out
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn a_timestamp_is_rfc_3339_in_utc_with_a_z() {
172        for (seconds, nanos, json) in [
173            (1_793_361_600, 0, r#""2026-10-30T12:00:00Z""#),
174            (1_793_361_600, 500_000_000, r#""2026-10-30T12:00:00.500Z""#),
175            (0, 1, r#""1970-01-01T00:00:00.000000001Z""#),
176        ] {
177            let t = Timestamp { seconds, nanos };
178            assert_eq!(serde_json::to_string(&t).unwrap(), json);
179            assert_eq!(serde_json::from_str::<Timestamp>(json).unwrap(), t);
180        }
181        let offset: Timestamp = serde_json::from_str(r#""2026-10-30T14:00:00+02:00""#).unwrap();
182        assert_eq!(offset.seconds, 1_793_361_600);
183        assert!(
184            serde_json::to_string(&Timestamp {
185                seconds: 0,
186                nanos: -1
187            })
188            .is_err()
189        );
190    }
191
192    #[test]
193    fn a_field_mask_is_one_string_of_camel_case_paths() {
194        let mask = FieldMask::new(["size_gib", "tags", "volume.storage_class"]);
195        let json = serde_json::to_string(&mask).unwrap();
196        assert_eq!(json, r#""sizeGib,tags,volume.storageClass""#);
197        assert_eq!(serde_json::from_str::<FieldMask>(&json).unwrap(), mask);
198        assert_eq!(
199            serde_json::from_str::<FieldMask>(r#""""#).unwrap(),
200            FieldMask::default()
201        );
202        assert!(serde_json::from_str::<FieldMask>(r#""size_gib""#).is_err());
203    }
204}