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}