#![allow(clippy::expect_used, clippy::unwrap_used)]
use std::collections::{BTreeMap, BTreeSet};
use serde_json::{Map, Value};
const DESCRIPTION: &str = "../../docs/research/tailscale-openapi.yaml";
type Objects = BTreeMap<String, BTreeSet<String>>;
type Enums = BTreeMap<String, Vec<String>>;
fn description() -> Value {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(DESCRIPTION);
let text = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("the vendored description is at {}: {e}", path.display()));
serde_norway::from_str(&text).expect("the vendored description is YAML")
}
fn schemas(document: &Value) -> &Map<String, Value> {
document["components"]["schemas"]
.as_object()
.expect("the description has component schemas")
}
fn read(document: &Value) -> (Objects, Enums) {
let mut objects = Objects::new();
let mut enums = Enums::new();
let all = schemas(document);
let mut record = |node: &Value, at: String| {
walk_unless_ref(node, &at, all, &mut objects, &mut enums);
};
for (name, schema) in all {
record(schema, name.clone());
}
for parameter in shared_parameters(document).values() {
if let Some((at, schema)) = parameter_schema(parameter, "") {
record(schema, at);
}
}
for (route, operations) in paths(document) {
let Some(operations) = operations.as_object() else {
continue;
};
for (verb, operation) in operations {
if verb == "parameters" {
for parameter in operation.as_array().into_iter().flatten() {
if let Some((at, schema)) = parameter_schema(parameter, &route) {
record(schema, at);
}
}
continue;
}
let prefix = format!("{} {route}", verb.to_uppercase());
for parameter in operation["parameters"].as_array().into_iter().flatten() {
if let Some((at, schema)) = parameter_schema(parameter, &prefix) {
record(schema, at);
}
}
for (at, schema) in body_schemas(&operation["requestBody"], &format!("{prefix} body")) {
record(schema, at);
}
for (code, response) in operation["responses"].as_object().into_iter().flatten() {
for (at, schema) in body_schemas(response, &format!("{prefix} {code}")) {
record(schema, at);
}
}
}
}
(objects, enums)
}
fn paths(document: &Value) -> Vec<(String, Value)> {
document["paths"]
.as_object()
.expect("the description has paths")
.iter()
.map(|(route, operations)| (route.clone(), operations.clone()))
.collect()
}
fn shared_parameters(document: &Value) -> &Map<String, Value> {
document["components"]["parameters"]
.as_object()
.expect("the description has shared parameters")
}
fn body_schemas<'a>(carrier: &'a Value, at: &str) -> Vec<(String, &'a Value)> {
let Some(content) = carrier.get("content").and_then(Value::as_object) else {
return Vec::new();
};
let carrying: Vec<_> = content
.iter()
.filter_map(|(media, body)| Some((media, body.get("schema")?)))
.collect();
let name = |media: &String| match carrying.len() {
1 => at.to_owned(),
_ => format!("{at} ({media})"),
};
carrying
.iter()
.map(|(media, schema)| (name(media), *schema))
.collect()
}
fn parameter_schema<'a>(parameter: &'a Value, prefix: &str) -> Option<(String, &'a Value)> {
if parameter.get("$ref").is_some() {
return None;
}
let name = parameter.get("name")?.as_str()?;
let schema = parameter.get("schema")?;
let at = if prefix.is_empty() {
format!("?{name}")
} else {
format!("{prefix} ?{name}")
};
Some((at, schema))
}
fn walk_unless_ref(
node: &Value,
path: &str,
all: &Map<String, Value>,
objects: &mut Objects,
enums: &mut Enums,
) {
if node.get("$ref").is_some() {
return;
}
walk(node, path, all, objects, enums);
}
fn walk(
node: &Value,
path: &str,
all: &Map<String, Value>,
objects: &mut Objects,
enums: &mut Enums,
) {
if let Some(values) = node.get("enum").and_then(Value::as_array) {
let values = values
.iter()
.map(|v| {
v.as_str()
.unwrap_or_else(|| panic!("{path} enumerates something that is not a string"))
.to_owned()
})
.collect();
enums.insert(path.to_owned(), values);
}
let properties = node.get("properties").and_then(Value::as_object);
let mut names: BTreeSet<String> = properties.map(named).unwrap_or_default();
for member in node
.get("allOf")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
if let Some(target) = member.get("$ref").and_then(Value::as_str) {
let target = resolve(target, all);
assert!(
target.get("allOf").is_none(),
"{path} composes {target:?}, which is itself composed; \
this walk reads one level and would drop the rest"
);
names.extend(
target
.get("properties")
.and_then(Value::as_object)
.map(named)
.unwrap_or_default(),
);
} else {
names.extend(
member
.get("properties")
.and_then(Value::as_object)
.map(named)
.unwrap_or_default(),
);
walk(member, path, all, objects, enums);
}
}
for union in ["anyOf", "oneOf"] {
for (which, branch) in node
.get(union)
.and_then(Value::as_array)
.into_iter()
.flatten()
.enumerate()
{
walk_unless_ref(
branch,
&format!("{path}|{union}[{which}]"),
all,
objects,
enums,
);
}
}
if !names.is_empty() {
objects.insert(path.to_owned(), names);
}
for (name, child) in properties.into_iter().flatten() {
walk(child, &format!("{path}.{name}"), all, objects, enums);
}
if let Some(items) = node.get("items") {
walk(items, &format!("{path}[]"), all, objects, enums);
}
if let Some(values) = node.get("additionalProperties").filter(|v| v.is_object()) {
walk(values, &format!("{path}{{}}"), all, objects, enums);
}
}
fn named(properties: &Map<String, Value>) -> BTreeSet<String> {
properties.keys().cloned().collect()
}
fn resolve<'a>(reference: &str, all: &'a Map<String, Value>) -> &'a Value {
let name = reference
.strip_prefix("#/components/schemas/")
.unwrap_or_else(|| panic!("{reference} points outside the component schemas"));
all.get(name)
.unwrap_or_else(|| panic!("{reference} points at a schema that is not there"))
}
const DEFERRED: &[(&str, &str)] = &[
];
fn deferred(path: &str) -> Option<&'static str> {
DEFERRED
.iter()
.find(|(at, _)| *at == path)
.map(|(_, why)| *why)
}
fn models() -> Objects {
tailscale_rest::models::shapes()
.map(|shape| {
(
shape.schema.to_owned(),
shape.fields.iter().map(|f| (*f).to_owned()).collect(),
)
})
.collect()
}
fn known() -> Enums {
tailscale_rest::models::known_values()
.map(|(path, values)| {
(
(*path).to_owned(),
values.iter().map(|v| (*v).to_owned()).collect(),
)
})
.collect()
}
fn differences(description: &Objects, models: &Objects) -> Vec<String> {
let mut found = Vec::new();
for (schema, properties) in description {
let Some(modelled) = models.get(schema) else {
if deferred(schema).is_none() {
found.push(format!(
"{schema} is described and has no model; its properties are {}",
list(properties)
));
}
continue;
};
let missing = list(&properties.difference(modelled).cloned().collect());
if !missing.is_empty() {
found.push(format!("{schema} is missing {missing}"));
}
let extra = list(&modelled.difference(properties).cloned().collect());
if !extra.is_empty() {
found.push(format!(
"{schema} models {extra}, which the description does not have"
));
}
}
for schema in models.keys() {
if !description.contains_key(schema) {
found.push(format!(
"{schema} is modelled and the description no longer describes it"
));
}
}
found
}
fn value_differences(description: &Enums, known: &Enums) -> Vec<String> {
let mut found = Vec::new();
for (path, values) in description {
match known.get(path) {
None => found.push(format!(
"{path} enumerates {} and no constant names them",
values.join(", ")
)),
Some(named) if named != values => found.push(format!(
"{path} enumerates [{}] and its constant says [{}]",
values.join(", "),
named.join(", ")
)),
Some(_) => {}
}
}
for path in known.keys() {
if !description.contains_key(path) {
found.push(format!(
"{path} has a constant and the description enumerates nothing there"
));
}
}
found
}
fn list(names: &BTreeSet<String>) -> String {
names.iter().cloned().collect::<Vec<_>>().join(", ")
}
#[test]
fn every_property_the_description_has_is_modelled() {
let (described, _) = read(&description());
let found = differences(&described, &models());
assert!(
found.is_empty(),
"the models and the vendored description disagree:\n {}",
found.join("\n ")
);
}
#[test]
fn every_documented_string_carries_the_values_the_description_gives_it() {
let (_, described) = read(&description());
let found = value_differences(&described, &known());
assert!(
found.is_empty(),
"the known values and the vendored description disagree:\n {}",
found.join("\n ")
);
}
#[test]
fn a_property_dropped_from_a_model_is_a_failure() {
let (described, _) = read(&description());
let mut short = models();
short
.get_mut("Device")
.expect("Device is modelled")
.remove("nodeId");
assert_eq!(
differences(&described, &short),
["Device is missing nodeId"],
"a field taken off a model has to be noticed"
);
let mut gone = models();
gone.remove("Key");
assert_eq!(
differences(&described, &gone),
[format!(
"Key is described and has no model; its properties are {}",
list(described.get("Key").expect("Key is described"))
)],
"a whole model going missing has to be noticed"
);
let mut invented = models();
invented
.get_mut("Contact")
.expect("Contact is modelled")
.insert("postalAddress".to_owned());
assert_eq!(
differences(&described, &invented),
["Contact models postalAddress, which the description does not have"],
"a field the description dropped has to be noticed too"
);
let mut stale = models();
stale.insert("Fax".to_owned(), BTreeSet::new());
assert_eq!(
differences(&described, &stale),
["Fax is modelled and the description no longer describes it"]
);
}
#[test]
fn a_value_the_description_adds_is_a_failure() {
let (_, described) = read(&description());
let mut short = known();
short
.get_mut("Key.keyType")
.expect("key types are named")
.pop();
assert_eq!(
value_differences(&described, &short),
[
"Key.keyType enumerates [auth, client, api, federated] and its constant says [auth, client, api]"
],
"a value the description gained has to be noticed"
);
let mut gone = known();
gone.remove("LogType");
assert_eq!(
value_differences(&described, &gone),
["LogType enumerates configuration, network and no constant names them"],
"a whole enumeration going unnamed has to be noticed"
);
}
#[test]
fn a_body_carrying_two_media_types_is_read_at_both() {
let document = description();
let validate = &document["paths"]["/tailnet/{tailnet}/acl/validate"]["post"];
let found = body_schemas(&validate["requestBody"], "body");
assert_eq!(found.len(), 2, "both media types carry a schema: {found:?}");
let (objects, _) = read(&document);
let test_case = objects
.get("POST /tailnet/{tailnet}/acl/validate body (application/json)|oneOf[0][]")
.expect("the test case the JSON branch describes");
assert!(
test_case.contains("srcPostureAttrs"),
"and it is read whole: {test_case:?}"
);
let keys = &document["paths"]["/tailnet/{tailnet}/keys"]["post"];
assert_eq!(
body_schemas(&keys["requestBody"], "body")
.into_iter()
.map(|(at, _)| at)
.collect::<Vec<_>>(),
["body"]
);
}
mod known_divergences {
use super::{description, schemas};
use serde_json::Value;
#[test]
fn the_endpoint_this_crate_gets_its_tokens_from_is_not_described() {
let document = description();
let paths = document["paths"]
.as_object()
.expect("the description has paths");
let oauth: Vec<_> = paths.keys().filter(|p| p.contains("oauth/token")).collect();
assert!(
oauth.is_empty(),
"the description now describes {oauth:?}; take the hand-written path out of token.rs"
);
}
#[test]
fn the_https_setting_is_called_two_things() {
let document = description();
let settings = &schemas(&document)["TailnetSettings"]["properties"];
assert!(settings.get("httpsEnabled").is_some());
assert!(
settings.get("httpsCertificates").is_none(),
"the schema now has the name the prose uses; the models can follow it"
);
let text = serde_json::to_string(&document).expect("the document re-serialises");
assert!(
text.contains("httpsCertificates"),
"the prose no longer disagrees with the schema; this note can go"
);
}
#[test]
fn split_dns_is_two_different_shapes() {
let document = description();
let all = schemas(&document);
let standalone = &all["SplitDns"]["additionalProperties"]["items"];
assert_eq!(
standalone.get("type").and_then(Value::as_str),
Some("string"),
"the older split-DNS shape has stopped taking bare addresses"
);
let nested =
&all["DnsConfiguration"]["properties"]["splitDNS"]["additionalProperties"]["items"];
assert_eq!(
nested.get("$ref").and_then(Value::as_str),
Some("#/components/schemas/DnsConfigurationResolver"),
"the two split-DNS shapes have converged; `dns.rs` need only model one"
);
}
#[test]
fn four_log_stream_fields_cannot_be_reached() {
let document = description();
let configuration = &schemas(&document)["LogstreamEndpointConfiguration"]["properties"];
let gcs: Vec<_> = configuration
.as_object()
.expect("it has properties")
.keys()
.filter(|name| name.starts_with("gcs"))
.collect();
assert_eq!(gcs.len(), 4, "the gcs fields: {gcs:?}");
assert!(
!destinations().contains(&"gcs"),
"the description now offers `gcs`; the four fields are reachable and this note can go"
);
assert!(
destinations().contains(&"crowdstrike"),
"the description has dropped `crowdstrike`, which the Go client never had"
);
}
fn destinations() -> Vec<&'static str> {
tailscale_rest::models::logging::DESTINATION_TYPES.to_vec()
}
#[test]
fn the_posture_providers_are_behind_the_go_client() {
let known = tailscale_rest::models::device::POSTURE_PROVIDERS;
for later in ["fleet", "huntress"] {
assert!(
!known.contains(&later),
"the description has caught up on {later}; drop it from this note"
);
}
}
#[test]
fn the_services_endpoint_is_spelled_one_way_here_and_another_in_the_go_client() {
let document = description();
let routes: Vec<_> = document["paths"]
.as_object()
.expect("the description has paths")
.keys()
.filter(|route| route.contains("services"))
.collect();
assert!(
routes.iter().any(|route| route.ends_with("/services")),
"the description no longer documents `/services`: {routes:?}"
);
assert!(
!routes.iter().any(|route| route.contains("vip-services")),
"the description now documents the Go client's spelling too: {routes:?}"
);
assert!(
!schemas(&document)["VIPServiceInfo"]["properties"]
.as_object()
.expect("it has properties")
.contains_key("annotations"),
"the description has gained the Go client's `annotations`; model it"
);
}
#[test]
fn a_key_listing_requires_a_parameter_it_calls_optional() {
let document = description();
let listing = &document["paths"]["/tailnet/{tailnet}/keys"]["get"];
let all = listing["parameters"]
.as_array()
.expect("the listing takes parameters")
.iter()
.find_map(|parameter| {
let reference = parameter.get("$ref")?.as_str()?;
reference.ends_with("/all").then_some(reference)
})
.expect("one of them is the shared `all`");
let shared = &document["components"]["parameters"]["all"];
assert_eq!(
shared["required"],
Value::Bool(true),
"{all} has stopped being required; the note can go"
);
assert!(
shared["description"]
.as_str()
.expect("it is described")
.starts_with("If set to true"),
"a required parameter has stopped being described as one that may be unset"
);
}
}
#[test]
fn the_walk_reaches_the_whole_document() {
let document = description();
let (objects, enums) = read(&document);
assert_eq!(schemas(&document).len(), 43, "named schemas");
assert_eq!(objects.len(), 91, "objects walked: {:?}", objects.keys());
assert_eq!(enums.len(), 33, "enumerations walked: {:?}", enums.keys());
assert_eq!(models().len(), 91, "models");
assert_eq!(DEFERRED.len(), 0, "deferrals");
}
#[test]
fn every_deferral_names_something_the_description_still_has() {
let (described, _) = read(&description());
let models = models();
for (path, why) in DEFERRED {
assert!(
described.contains_key(*path),
"{path} is deferred ({why}) and the description no longer has it"
);
assert!(
!models.contains_key(*path),
"{path} is deferred ({why}) and is also modelled; the row can go"
);
}
}