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-three 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        #[derive(Debug, Clone, PartialEq, ::serde::Serialize, ::serde::Deserialize)]
145        pub struct $name {
146            $(
147                $(#[doc = $field_doc])*
148                #[serde(rename = $json, default, skip_serializing_if = "Option::is_none")]
149                pub $field: ::std::option::Option<$ty>,
150            )*
151            /// Everything the description did not mention, kept so that a
152            /// control plane ahead of this build still answers usefully.
153            #[serde(flatten)]
154            pub unknown: $crate::models::Unknown,
155        }
156
157        impl $name {
158            /// The path the vendored description reaches this by.
159            pub const SCHEMA: &'static str = {
160                #[allow(unused_mut, unused_assignments)]
161                let mut schema = ::std::stringify!($name);
162                $( schema = $schema; )?
163                schema
164            };
165
166            /// The JSON names of the fields above, in declaration order.
167            /// Not the Rust names.
168            pub const FIELDS: &'static [&'static str] = &[$($json),*];
169        }
170
171        $crate::model_entries! {
172            @shapes[
173                $($shape,)*
174                $crate::models::ModelShape {
175                    schema: <$name>::SCHEMA,
176                    fields: <$name>::FIELDS,
177                },
178            ]
179            $($rest)*
180        }
181    };
182
183    // A schema another model already has the shape of.
184    (
185        @shapes[$($shape:expr,)*]
186        $(#[doc = $doc:literal])*
187        $alias:ident as $schema:literal is $name:ident;
188        $($rest:tt)*
189    ) => {
190        $(#[doc = $doc])*
191        pub type $alias = $name;
192
193        $crate::model_entries! {
194            @shapes[
195                $($shape,)*
196                $crate::models::ModelShape { schema: $schema, fields: <$name>::FIELDS },
197            ]
198            $($rest)*
199        }
200    };
201
202    // Nothing left to read.
203    (@shapes[$($shape:expr,)*]) => {
204        /// Every model declared in this module, in declaration order.
205        pub const SHAPES: &[$crate::models::ModelShape] = &[$($shape,)*];
206    };
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    #[test]
214    fn a_field_the_description_never_mentioned_survives_a_round_trip() {
215        // The criterion in its smallest form: a body from a control plane
216        // ahead of this build parses, the new field is readable, and it is
217        // still there when the model is handed on to a caller.
218        let body = serde_json::json!({
219            "id": "example-1",
220            "hostname": "laptop",
221            "quantumEntangled": true,
222        });
223        let device: device::Device = serde_json::from_value(body.clone()).expect("it parses");
224        assert_eq!(device.id.as_deref(), Some("example-1"));
225        assert_eq!(
226            device.unknown.get("quantumEntangled"),
227            Some(&Value::Bool(true)),
228            "the field is retrievable, not merely tolerated"
229        );
230        assert_eq!(
231            serde_json::to_value(&device).expect("it serialises"),
232            body,
233            "and it comes back out as it went in"
234        );
235    }
236
237    #[test]
238    fn a_field_that_is_absent_is_absent_rather_than_null() {
239        // `skip_serializing_if` is what makes a model usable as a request
240        // body: a `PATCH` that spelled every unset field as `null` would be
241        // asking the control plane to clear them.
242        let empty = device::Device {
243            id: Some("example-1".to_owned()),
244            ..serde_json::from_value(serde_json::json!({})).expect("an empty body is a device")
245        };
246        assert_eq!(
247            serde_json::to_value(&empty).expect("it serialises"),
248            serde_json::json!({"id": "example-1"})
249        );
250    }
251
252    #[test]
253    fn every_model_names_a_distinct_schema() {
254        // Two models claiming one path would have the drift test check one of
255        // them twice and the other never.
256        let mut seen = std::collections::BTreeSet::new();
257        for shape in shapes() {
258            assert!(
259                seen.insert(shape.schema),
260                "{} is declared twice",
261                shape.schema
262            );
263        }
264        let mut paths = std::collections::BTreeSet::new();
265        for (path, _) in known_values() {
266            assert!(paths.insert(*path), "{path} is declared twice");
267        }
268    }
269
270    #[test]
271    fn a_secret_field_is_not_printed_by_a_model() {
272        // The reason `Secret` is in a model at all (Q62): these structs derive
273        // `Debug`, and a minted key reaching a `tracing` field is how it ends
274        // up in a log.
275        let minted: key::Key = serde_json::from_value(serde_json::json!({
276            "id": "kExAmPlE",
277            "key": "tskey-auth-example1CNTRL-secretpart",
278        }))
279        .expect("it parses");
280        assert!(!format!("{minted:?}").contains("secretpart"), "{minted:?}");
281        assert_eq!(
282            minted.key.as_ref().map(crate::Secret::expose),
283            Some("tskey-auth-example1CNTRL-secretpart"),
284            "and is still readable by whoever asked for it"
285        );
286    }
287}