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}