Skip to main content

tailscale_rest/models/
mod.rs

1//! The shapes the control plane sends, written by hand (ADR-0003).
2//!
3//! Every model here is a struct of optional fields plus a map of everything
4//! the description did not mention, so a control plane that grows a field
5//! parses today and the new field is readable without a release. Nothing is
6//! renamed on the way through: ADR-0004 has Tailscale's bodies come back in
7//! Tailscale's shape, so the JSON names are Tailscale's and the Rust names
8//! exist only to be spelled in Rust.
9//!
10//! Enums are documented strings rather than Rust enums (Q60). None of the
11//! description's thirty-four enumerations is a closed set; each is a list of
12//! what exists today, so the values live in a `&[&str]` constant beside the
13//! field — which is what a tool's parameter description quotes — and the
14//! drift test asserts the constant still says what the document says.
15//!
16//! [`shapes`] is what makes that test possible: the `model!` macro writes the
17//! struct and the list of JSON names from one source, so a field cannot be
18//! deleted from a model and left in the table (Q61).
19
20use std::collections::BTreeMap;
21
22use serde_json::Value;
23
24/// Whatever the description did not mention.
25///
26/// A `BTreeMap` rather than a `HashMap` so that a model serialised back out
27/// puts its unknown fields in a stable order, which keeps a tool's answer the
28/// same from one call to the next.
29pub type Unknown = BTreeMap<String, Value>;
30
31/// One model, as the drift test sees it.
32///
33/// `schema` is the path the description reaches this object by: a name from
34/// `components/schemas` for the thirty-four that have one, a dotted path like
35/// `Device.clientConnectivity` for the eleven inline objects that do not, and
36/// a route like `POST /tailnet/{tailnet}/keys body` for one the description
37/// spells out where it is used.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub struct ModelShape {
40    pub schema: &'static str,
41    /// The JSON names, in declaration order. Not the Rust names.
42    pub fields: &'static [&'static str],
43}
44
45/// Declare the model modules, and the two tables the drift test reads.
46///
47/// The list appears once. Written out three times — once to declare the
48/// modules, once for [`shapes`], once for [`known_values`] — a module added to
49/// one and forgotten in the others would be a model the drift test never
50/// checks, which is a green test over an unchecked shape.
51macro_rules! modules {
52    ($($module:ident),* $(,)?) => {
53        $(pub mod $module;)*
54
55        /// Every model in this crate, for the drift test to check the
56        /// description against.
57        pub fn shapes() -> impl Iterator<Item = &'static ModelShape> {
58            [$($module::SHAPES),*].into_iter().flatten()
59        }
60
61        /// Every documented string in this crate. The drift test requires this
62        /// to cover the description's enumerations exactly — no path missing,
63        /// no path left over, and every list equal.
64        pub fn known_values() -> impl Iterator<Item = &'static KnownValues> {
65            [$($module::KNOWN_VALUES),*].into_iter().flatten()
66        }
67    };
68}
69
70modules!(
71    device, dns, key, logging, policy, service, tailnet, user, webhook
72);
73
74/// A documented string's known values, keyed by where the description puts it.
75///
76/// The path is where the description puts the enumeration: `Key.keyType` for a
77/// property of a named schema, `Webhook.subscriptions[]` for an array's items,
78/// `LogType` for an enumeration that is a schema in its own right, `?fields`
79/// for a parameter the whole document shares, and `POST
80/// /tailnet/{tailnet}/keys body.keyType` for one belonging to a single call.
81pub type KnownValues = (&'static str, &'static [&'static str]);
82
83/// Declare models.
84///
85/// One block per module. Each entry names the Rust type, the path the
86/// description reaches it by when that differs from the type's name, and its
87/// fields as `rust_name: "jsonName" => Type`. The JSON name is written once
88/// and becomes both the `serde` rename and the row in [`SHAPES`], which is
89/// what stops the two drifting apart (Q61).
90///
91/// ```ignore
92/// model! {
93///     /// A machine in the tailnet.
94///     Device {
95///         id: "id" => String,
96///         node_id: "nodeId" => String,
97///     }
98///
99///     /// How this device is reaching the network.
100///     ClientConnectivity as "Device.clientConnectivity" {
101///         endpoints: "endpoints" => Vec<String>,
102///     }
103///
104///     /// The same six fields, so the same struct.
105///     VipServiceInfoPut as "VIPServiceInfoPut" is VipServiceInfo;
106/// }
107/// ```
108///
109/// Every field is optional and every type is the one inside the `Option`: the
110/// control plane omits what does not apply, and a model that demanded a field
111/// would fail to parse a body a caller could have used.
112///
113/// The last form declares a schema that some other model already has the shape
114/// of. The description does that where one body is another under a second
115/// name, and a second struct there would be six duplicated fields and a
116/// conversion between them that says nothing.
117///
118/// [`SHAPES`]: crate::models::ModelShape
119#[macro_export]
120macro_rules! model {
121    ($($declarations:tt)*) => {
122        $crate::model_entries! { @shapes[] $($declarations)* }
123    };
124}
125
126/// [`model!`]'s worker, which reads one declaration at a time so that a
127/// struct and a bare shape can sit in the same block.
128#[doc(hidden)]
129#[macro_export]
130macro_rules! model_entries {
131    // A struct, and the shape that describes it.
132    (
133        @shapes[$($shape:expr,)*]
134        $(#[doc = $doc:literal])*
135        $name:ident $(as $schema:literal)? {
136            $(
137                $(#[doc = $field_doc:literal])*
138                $field:ident : $json:literal => $ty:ty
139            ),* $(,)?
140        }
141        $($rest:tt)*
142    ) => {
143        $(#[doc = $doc])*
144        // Non-exhaustive so that a field the next description adds is a minor
145        // release; outside this crate a model starts from `default()`.
146        #[derive(Debug, Clone, Default, PartialEq, ::serde::Serialize, ::serde::Deserialize)]
147        #[non_exhaustive]
148        pub struct $name {
149            $(
150                $(#[doc = $field_doc])*
151                #[serde(rename = $json, default, skip_serializing_if = "Option::is_none")]
152                pub $field: ::std::option::Option<$ty>,
153            )*
154            /// Everything the description did not mention, kept so that a
155            /// control plane ahead of this build still answers usefully.
156            #[serde(flatten)]
157            pub unknown: $crate::models::Unknown,
158        }
159
160        impl $name {
161            /// The path the vendored description reaches this by.
162            pub const SCHEMA: &'static str = {
163                #[allow(unused_mut, unused_assignments)]
164                let mut schema = ::std::stringify!($name);
165                $( schema = $schema; )?
166                schema
167            };
168
169            /// The JSON names of the fields above, in declaration order.
170            /// Not the Rust names.
171            pub const FIELDS: &'static [&'static str] = &[$($json),*];
172        }
173
174        $crate::model_entries! {
175            @shapes[
176                $($shape,)*
177                $crate::models::ModelShape {
178                    schema: <$name>::SCHEMA,
179                    fields: <$name>::FIELDS,
180                },
181            ]
182            $($rest)*
183        }
184    };
185
186    // A schema another model already has the shape of.
187    (
188        @shapes[$($shape:expr,)*]
189        $(#[doc = $doc:literal])*
190        $alias:ident as $schema:literal is $name:ident;
191        $($rest:tt)*
192    ) => {
193        $(#[doc = $doc])*
194        pub type $alias = $name;
195
196        $crate::model_entries! {
197            @shapes[
198                $($shape,)*
199                $crate::models::ModelShape { schema: $schema, fields: <$name>::FIELDS },
200            ]
201            $($rest)*
202        }
203    };
204
205    // Nothing left to read.
206    (@shapes[$($shape:expr,)*]) => {
207        /// Every model declared in this module, in declaration order.
208        pub const SHAPES: &[$crate::models::ModelShape] = &[$($shape,)*];
209    };
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    #[test]
217    fn a_field_the_description_never_mentioned_survives_a_round_trip() {
218        // The criterion in its smallest form: a body from a control plane
219        // ahead of this build parses, the new field is readable, and it is
220        // still there when the model is handed on to a caller.
221        let body = serde_json::json!({
222            "id": "example-1",
223            "hostname": "laptop",
224            "quantumEntangled": true,
225        });
226        let device: device::Device = serde_json::from_value(body.clone()).expect("it parses");
227        assert_eq!(device.id.as_deref(), Some("example-1"));
228        assert_eq!(
229            device.unknown.get("quantumEntangled"),
230            Some(&Value::Bool(true)),
231            "the field is retrievable, not merely tolerated"
232        );
233        assert_eq!(
234            serde_json::to_value(&device).expect("it serialises"),
235            body,
236            "and it comes back out as it went in"
237        );
238    }
239
240    #[test]
241    fn a_field_that_is_absent_is_absent_rather_than_null() {
242        // `skip_serializing_if` is what makes a model usable as a request
243        // body: a `PATCH` that spelled every unset field as `null` would be
244        // asking the control plane to clear them.
245        let empty = device::Device {
246            id: Some("example-1".to_owned()),
247            ..serde_json::from_value(serde_json::json!({})).expect("an empty body is a device")
248        };
249        assert_eq!(
250            serde_json::to_value(&empty).expect("it serialises"),
251            serde_json::json!({"id": "example-1"})
252        );
253    }
254
255    #[test]
256    fn every_model_names_a_distinct_schema() {
257        // Two models claiming one path would have the drift test check one of
258        // them twice and the other never.
259        let mut seen = std::collections::BTreeSet::new();
260        for shape in shapes() {
261            assert!(
262                seen.insert(shape.schema),
263                "{} is declared twice",
264                shape.schema
265            );
266        }
267        let mut paths = std::collections::BTreeSet::new();
268        for (path, _) in known_values() {
269            assert!(paths.insert(*path), "{path} is declared twice");
270        }
271    }
272
273    #[test]
274    fn a_secret_field_is_not_printed_by_a_model() {
275        // The reason `Secret` is in a model at all (Q62): these structs derive
276        // `Debug`, and a minted key reaching a `tracing` field is how it ends
277        // up in a log.
278        let minted: key::Key = serde_json::from_value(serde_json::json!({
279            "id": "kExAmPlE",
280            "key": "tskey-auth-example1CNTRL-secretpart",
281        }))
282        .expect("it parses");
283        assert!(!format!("{minted:?}").contains("secretpart"), "{minted:?}");
284        assert_eq!(
285            minted.key.as_ref().map(crate::Secret::expose),
286            Some("tskey-auth-example1CNTRL-secretpart"),
287            "and is still readable by whoever asked for it"
288        );
289    }
290}