use std::collections::{BTreeSet, HashMap};
use clickhouse_cloud_api::{BETA_OPERATIONS, DEPRECATED_FIELDS};
use serde_json::Value;
const SPEC_JSON: &str = include_str!("../clickhouse_cloud_openapi.json");
const CLIENT_RS: &str = include_str!("../src/client.rs");
const MODELS_RS: &str = include_str!("../src/models.rs");
const LIVE_SPEC_URL: &str = "https://api.clickhouse.cloud/v1";
#[test]
fn client_methods_cover_every_openapi_operation() {
assert_client_operation_coverage(&serde_json::from_str(SPEC_JSON).unwrap());
}
#[test]
fn models_cover_every_openapi_component_schema() {
assert_model_schema_coverage(&serde_json::from_str(SPEC_JSON).unwrap());
}
#[test]
fn referenced_component_schemas_have_generated_model_types() {
assert_ref_schema_coverage(&serde_json::from_str(SPEC_JSON).unwrap());
}
#[tokio::test]
#[ignore = "hits the live published ClickHouse OpenAPI spec"]
async fn client_methods_cover_live_openapi_operations() {
let spec = load_live_spec().await;
assert_client_operation_coverage(&spec);
}
#[tokio::test]
#[ignore = "hits the live published ClickHouse OpenAPI spec"]
async fn models_cover_live_openapi_component_schemas() {
let spec = load_live_spec().await;
assert_model_schema_coverage(&spec);
}
#[tokio::test]
#[ignore = "hits the live published ClickHouse OpenAPI spec"]
async fn referenced_live_component_schemas_have_generated_model_types() {
let spec = load_live_spec().await;
assert_ref_schema_coverage(&spec);
}
fn spec_operation_ids(spec: &Value) -> BTreeSet<String> {
let mut operation_ids = BTreeSet::new();
for path_item in spec["paths"].as_object().unwrap().values() {
for (method, operation) in path_item.as_object().unwrap() {
if matches!(
method.as_str(),
"get" | "put" | "post" | "delete" | "patch" | "options" | "head" | "trace"
) {
operation_ids.insert(camel_to_snake(
operation["operationId"].as_str().unwrap(),
));
}
}
}
operation_ids
}
fn spec_schema_type_names(spec: &Value) -> BTreeSet<String> {
spec["components"]["schemas"]
.as_object()
.unwrap()
.keys()
.map(|schema_name| pascalize_identifier(schema_name))
.collect()
}
const NON_OPENAPI_CLIENT_METHODS: &[&str] = &["run_query"];
fn assert_client_operation_coverage(spec: &Value) {
let spec_operations = spec_operation_ids(spec);
let client_methods = public_items(CLIENT_RS, "pub async fn ");
let exempt: BTreeSet<String> = NON_OPENAPI_CLIENT_METHODS
.iter()
.map(|s| (*s).to_string())
.collect();
let missing: Vec<_> = spec_operations.difference(&client_methods).cloned().collect();
let extras: Vec<_> = client_methods
.difference(&spec_operations)
.filter(|m| !exempt.contains(m.as_str()))
.cloned()
.collect();
assert!(
missing.is_empty() && extras.is_empty(),
"OpenAPI operation coverage mismatch.\nMissing client methods: {:?}\nExtra client methods: {:?}",
missing,
extras
);
}
fn assert_model_schema_coverage(spec: &Value) {
let spec_schemas = spec_schema_type_names(spec);
let model_types = model_type_names();
let missing: Vec<_> = spec_schemas.difference(&model_types).cloned().collect();
assert!(
missing.is_empty(),
"OpenAPI schema coverage mismatch.\nMissing model types: {:?}",
missing
);
}
fn assert_ref_schema_coverage(spec: &Value) {
let schema_definitions = spec["components"]["schemas"]
.as_object()
.unwrap()
.keys()
.cloned()
.collect::<BTreeSet<_>>();
let referenced_schemas = collect_schema_refs(spec);
let model_types = model_type_names();
let missing_definitions: Vec<_> = referenced_schemas
.iter()
.filter(|schema_name| !schema_definitions.contains(*schema_name))
.cloned()
.collect();
let missing_models: Vec<_> = referenced_schemas
.iter()
.map(|schema_name| pascalize_identifier(schema_name))
.filter(|type_name| !model_types.contains(type_name))
.collect();
assert!(
missing_definitions.is_empty() && missing_models.is_empty(),
"Referenced component schema coverage mismatch.\nMissing schema definitions: {:?}\nMissing model types: {:?}",
missing_definitions,
missing_models
);
}
fn model_type_names() -> BTreeSet<String> {
public_items(MODELS_RS, "pub struct ")
.into_iter()
.chain(public_items(MODELS_RS, "pub enum "))
.chain(public_items(MODELS_RS, "pub type "))
.collect::<BTreeSet<_>>()
}
fn public_items(source: &str, needle: &str) -> BTreeSet<String> {
source
.lines()
.filter_map(|line| line.trim_start().strip_prefix(needle))
.filter_map(identifier_prefix)
.map(str::to_string)
.collect()
}
fn identifier_prefix(value: &str) -> Option<&str> {
let end = value
.char_indices()
.find(|(_, ch)| !(ch.is_ascii_alphanumeric() || *ch == '_'))
.map(|(idx, _)| idx)
.unwrap_or(value.len());
if end == 0 {
None
} else {
Some(&value[..end])
}
}
fn camel_to_snake(value: &str) -> String {
let mut output = String::with_capacity(value.len());
let mut previous: Option<char> = None;
for ch in value.chars() {
if ch.is_ascii_uppercase() {
if matches!(previous, Some(prev) if prev.is_ascii_lowercase() || prev.is_ascii_digit())
{
output.push('_');
}
output.push(ch.to_ascii_lowercase());
} else {
output.push(ch);
}
previous = Some(ch);
}
output
}
fn collect_schema_refs(value: &Value) -> BTreeSet<String> {
let mut refs = BTreeSet::new();
collect_schema_refs_inner(value, &mut refs);
refs
}
fn collect_schema_refs_inner(value: &Value, refs: &mut BTreeSet<String>) {
match value {
Value::Object(map) => {
if let Some(reference) = map.get("$ref").and_then(Value::as_str)
&& let Some(schema_name) = reference.strip_prefix("#/components/schemas/")
{
refs.insert(schema_name.to_string());
}
for child in map.values() {
collect_schema_refs_inner(child, refs);
}
}
Value::Array(items) => {
for item in items {
collect_schema_refs_inner(item, refs);
}
}
_ => {}
}
}
async fn load_live_spec() -> Value {
let response = reqwest::Client::new()
.get(std::env::var("CLICKHOUSE_OPENAPI_SPEC_URL").unwrap_or_else(|_| LIVE_SPEC_URL.to_string()))
.send()
.await
.unwrap()
.error_for_status()
.unwrap();
response.json().await.unwrap()
}
fn pascalize_identifier(value: &str) -> String {
let mut output = String::with_capacity(value.len());
let mut uppercase_next = true;
for ch in value.chars() {
if ch.is_ascii_alphanumeric() {
if uppercase_next {
output.push(ch.to_ascii_uppercase());
uppercase_next = false;
} else {
output.push(ch);
}
} else {
uppercase_next = true;
}
}
output
}
#[test]
fn field_optionality_matches_spec() {
assert_field_optionality(&serde_json::from_str(SPEC_JSON).unwrap());
}
#[tokio::test]
#[ignore = "hits the live published ClickHouse OpenAPI spec"]
async fn field_optionality_matches_live_spec() {
let spec = load_live_spec().await;
assert_field_optionality(&spec);
}
#[test]
fn struct_fields_cover_every_spec_property() {
assert_field_coverage(&serde_json::from_str(SPEC_JSON).unwrap());
}
#[tokio::test]
#[ignore = "hits the live published ClickHouse OpenAPI spec"]
async fn struct_fields_cover_every_live_spec_property() {
let spec = load_live_spec().await;
assert_field_coverage(&spec);
}
#[test]
fn struct_fields_have_no_extras_vs_spec() {
assert_no_extra_struct_fields(&serde_json::from_str(SPEC_JSON).unwrap());
}
#[tokio::test]
#[ignore = "hits the live published ClickHouse OpenAPI spec"]
async fn struct_fields_have_no_extras_vs_live_spec() {
let spec = load_live_spec().await;
assert_no_extra_struct_fields(&spec);
}
const OPTIONALITY_EXEMPTIONS: &[(&str, &str)] = &[
("ServicePostRequest", "byocId"),
("ServicePostRequest", "complianceType"),
("ServicePostRequest", "dataWarehouseId"),
("ServicePostRequest", "enableCoreDumps"),
("ServicePostRequest", "endpoints"),
("ServicePostRequest", "hasTransparentDataEncryption"),
("ServicePostRequest", "idleScaling"),
("ServicePostRequest", "idleTimeoutMinutes"),
("ServicePostRequest", "isReadonly"),
("ServicePostRequest", "maxReplicaMemoryGb"),
("ServicePostRequest", "maxTotalMemoryGb"),
("ServicePostRequest", "minReplicaMemoryGb"),
("ServicePostRequest", "minTotalMemoryGb"),
("ServicePostRequest", "numReplicas"),
("ServicePostRequest", "privateEndpointIds"),
("ServicePostRequest", "privatePreviewTermsChecked"),
("ServicePostRequest", "profile"),
("ServicePostRequest", "releaseChannel"),
("ServicePostRequest", "tags"),
("ServicePostRequest", "tier"),
("ClickPipePostSource", "postgres"),
("ClickPipePatchSource", "postgres"),
("ClickPipePostRequest", "scaling"),
("ClickPipePostRequest", "settings"),
("ClickPipePostgresPipeSettings", "publicationName"),
("ClickPipePostgresPipeSettings", "replicationSlotName"),
("ClickPipePostgresPipeSettings", "syncIntervalSeconds"),
("ClickPipePostgresPipeSettings", "pullBatchSize"),
("ClickPipePostgresPipeSettings", "initialLoadParallelism"),
("ClickPipePostgresPipeSettings", "snapshotNumRowsPerPartition"),
("ClickPipePostgresPipeSettings", "snapshotNumberOfParallelTables"),
("ClickPipeMutateDestination", "table"),
("ClickPipeMutateDestination", "managedTable"),
("ClickPipeMutateDestination", "tableDefinition"),
("ClickPipeMutatePostgresSource", "caCertificate"),
("ClickPipeMutatePostgresSource", "iamRole"),
("ClickPipeMutatePostgresSource", "tlsHost"),
("ApiKeyPostRequest", "roles"),
("ApiKeyPostRequest", "hashData"),
("InvitationPostRequest", "role"),
("OrganizationPrivateEndpointsPatch", "add"),
("CreateReversePrivateEndpoint", "customPrivateDnsMappings"),
("ReversePrivateEndpoint", "customPrivateDnsMappings"),
("CustomPrivateDnsMapping", "privateDnsName"),
];
fn assert_field_optionality(spec: &Value) {
let schemas = spec["components"]["schemas"].as_object().unwrap();
let model_fields = parse_model_fields(MODELS_RS);
let exemptions: BTreeSet<(&str, &str)> = OPTIONALITY_EXEMPTIONS.iter().copied().collect();
let mut exemptions_hit: BTreeSet<(&str, &str)> = BTreeSet::new();
let mut mismatches = Vec::new();
for (spec_name, schema) in schemas {
let rust_name = pascalize_identifier(spec_name);
let fields = match model_fields.get(&rust_name) {
Some(f) => f,
None => continue, };
let props = match schema.get("properties").and_then(Value::as_object) {
Some(p) => p,
None => continue,
};
let required_fields = resolve_required_fields(spec_name, schema);
for (prop_name, _prop_schema) in props {
let is_required = required_fields.contains(prop_name.as_str());
let field_info = match fields.get(prop_name.as_str()) {
Some(f) => f,
None => continue, };
let would_mismatch = (is_required && field_info.is_option)
|| (!is_required && !field_info.is_option);
if !would_mismatch {
continue;
}
if exemptions.iter().any(|(s, f)| *s == rust_name && *f == prop_name.as_str()) {
exemptions_hit.insert(
*exemptions.iter().find(|(s, f)| *s == rust_name && *f == prop_name.as_str()).unwrap()
);
let direction = if is_required {
"spec=required, model=Option<T>"
} else {
"spec=optional, model=T"
};
eprintln!(
"NOTE: {}.{} optionality exempted ({}) — see OPTIONALITY_EXEMPTIONS",
rust_name, prop_name, direction
);
continue;
}
if is_required && field_info.is_option {
mismatches.push(format!(
"{}.{} should be required (T) but is Option<T>",
rust_name, prop_name
));
} else {
mismatches.push(format!(
"{}.{} should be optional (Option<T>) but is T",
rust_name, prop_name
));
}
}
}
let stale: Vec<_> = exemptions
.difference(&exemptions_hit)
.map(|(s, f)| format!("({}, {})", s, f))
.collect();
if !stale.is_empty() {
eprintln!(
"NOTE: {} stale optionality exemption(s) can be removed: {}",
stale.len(),
stale.join(", ")
);
}
assert!(
stale.is_empty(),
"Stale OPTIONALITY_EXEMPTIONS (spec now agrees with model):\n{}",
stale.join("\n")
);
assert!(
mismatches.is_empty(),
"Field optionality mismatches ({} total):\n{}",
mismatches.len(),
mismatches.join("\n")
);
}
fn assert_field_coverage(spec: &Value) {
let schemas = spec["components"]["schemas"].as_object().unwrap();
let model_fields = parse_model_fields(MODELS_RS);
let mut missing = Vec::new();
for (spec_name, schema) in schemas {
let rust_name = pascalize_identifier(spec_name);
let fields = match model_fields.get(&rust_name) {
Some(f) => f,
None => continue, };
let props = match schema.get("properties").and_then(Value::as_object) {
Some(p) => p,
None => continue,
};
for prop_name in props.keys() {
if !fields.contains_key(prop_name.as_str()) {
missing.push(format!("{}.{}", rust_name, prop_name));
}
}
}
assert!(
missing.is_empty(),
"Spec properties missing from Rust structs ({} total):\n{}",
missing.len(),
missing.join("\n")
);
}
const EXTRA_FIELD_EXEMPTIONS: &[(&str, &str)] = &[];
fn assert_no_extra_struct_fields(spec: &Value) {
let schemas = spec["components"]["schemas"].as_object().unwrap();
let model_fields = parse_model_fields(MODELS_RS);
let exemptions: BTreeSet<(&str, &str)> = EXTRA_FIELD_EXEMPTIONS.iter().copied().collect();
let mut exemptions_hit: BTreeSet<(&str, &str)> = BTreeSet::new();
let mut extras = Vec::new();
for (spec_name, schema) in schemas {
let rust_name = pascalize_identifier(spec_name);
let fields = match model_fields.get(&rust_name) {
Some(f) => f,
None => continue, };
let props = match schema.get("properties").and_then(Value::as_object) {
Some(p) if !p.is_empty() => p,
_ => continue,
};
for spec_field in fields.keys() {
if props.contains_key(spec_field.as_str()) {
continue;
}
if exemptions
.iter()
.any(|(s, f)| *s == rust_name && *f == spec_field.as_str())
{
exemptions_hit.insert(
*exemptions
.iter()
.find(|(s, f)| *s == rust_name && *f == spec_field.as_str())
.unwrap(),
);
eprintln!(
"NOTE: {}.{} extra-field exempted — see EXTRA_FIELD_EXEMPTIONS",
rust_name, spec_field
);
continue;
}
extras.push(format!("{}.{}", rust_name, spec_field));
}
}
let stale: Vec<_> = exemptions
.difference(&exemptions_hit)
.map(|(s, f)| format!("({}, {})", s, f))
.collect();
assert!(
stale.is_empty(),
"Stale EXTRA_FIELD_EXEMPTIONS (struct field now matches the spec or was removed):\n{}",
stale.join("\n")
);
extras.sort();
assert!(
extras.is_empty(),
"Struct fields with no matching spec property ({} total):\n{}\n\
A field listed here was removed from (or never existed in) its OpenAPI \
schema but still lives in models.rs. Remove it, or — if it's an \
intentional code-only field — add it to EXTRA_FIELD_EXEMPTIONS.",
extras.len(),
extras.join("\n")
);
}
const PARTIAL_REQUIRED_SCHEMAS: &[&str] = &[
"Service",
"ServiceScalingPatchResponse",
];
fn resolve_required_fields<'a>(schema_name: &str, schema: &'a Value) -> BTreeSet<&'a str> {
let props = match schema.get("properties").and_then(Value::as_object) {
Some(p) => p,
None => return BTreeSet::new(),
};
if schema_name.contains("Patch") && schema_name.ends_with("Request") {
return BTreeSet::new();
}
let is_partial = PARTIAL_REQUIRED_SCHEMAS.contains(&schema_name);
let required_names: BTreeSet<&str> = if is_partial {
let mut names: BTreeSet<&str> = schema
.get("required")
.and_then(Value::as_array)
.map(|arr| arr.iter().filter_map(Value::as_str).collect())
.unwrap_or_default();
for (name, prop) in props {
let desc = prop.get("description").and_then(Value::as_str).unwrap_or("");
if !desc.starts_with("Optional") {
names.insert(name.as_str());
}
}
names
} else if let Some(required) = schema.get("required").and_then(Value::as_array) {
required.iter().filter_map(Value::as_str).collect()
} else {
props
.iter()
.filter(|(_, prop)| {
let desc = prop
.get("description")
.and_then(Value::as_str)
.unwrap_or("");
!desc.starts_with("Optional")
})
.map(|(name, _)| name.as_str())
.collect()
};
required_names
.into_iter()
.filter(|name| {
if let Some(prop) = props.get(*name) {
!is_field_nullable(prop)
} else {
false
}
})
.collect()
}
fn is_field_nullable(prop: &Value) -> bool {
if let Some(types) = prop.get("type").and_then(Value::as_array)
&& types.iter().any(|t| t.as_str() == Some("null"))
{
return true;
}
for key in &["oneOf", "anyOf"] {
if let Some(variants) = prop.get(*key).and_then(Value::as_array)
&& variants.iter().any(|v| v.get("type").and_then(Value::as_str) == Some("null"))
{
return true;
}
}
false
}
struct FieldInfo {
is_option: bool,
deprecated_marker: bool,
}
fn parse_model_fields(source: &str) -> HashMap<String, HashMap<String, FieldInfo>> {
let mut result: HashMap<String, HashMap<String, FieldInfo>> = HashMap::new();
let lines: Vec<&str> = source.lines().collect();
let mut i = 0;
while i < lines.len() {
let line = lines[i].trim_start();
if let Some(rest) = line.strip_prefix("pub struct ")
&& let Some(struct_name) = identifier_prefix(rest)
{
let struct_name = struct_name.to_string();
i += 1;
let mut fields: HashMap<String, FieldInfo> = HashMap::new();
let mut pending_rename: Option<String> = None;
let mut pending_deprecated_marker = false;
while i < lines.len() {
let line = lines[i].trim();
if line == "}" {
break;
}
if line.contains("#[cfg(feature = \"deprecated-fields\")]") {
pending_deprecated_marker = true;
}
if line.starts_with("#[serde(")
&& let Some(rename) = extract_serde_rename(line)
{
pending_rename = Some(rename.to_string());
}
if let Some(rest) = line.strip_prefix("pub ")
&& let Some(colon_pos) = rest.find(':')
{
let rust_field_name = rest[..colon_pos].trim();
let type_str = rest[colon_pos + 1..].trim().trim_end_matches(',');
let is_option = type_str.starts_with("Option<");
let spec_name = pending_rename.take().unwrap_or_else(|| {
rust_field_name
.strip_prefix("r#")
.unwrap_or(rust_field_name)
.to_string()
});
fields.insert(
spec_name,
FieldInfo {
is_option,
deprecated_marker: pending_deprecated_marker,
},
);
pending_deprecated_marker = false;
}
i += 1;
}
result.insert(struct_name, fields);
}
i += 1;
}
result
}
fn extract_serde_rename(serde_line: &str) -> Option<&str> {
let start = serde_line.find("rename = \"")?;
let value_start = start + "rename = \"".len();
let end = serde_line[value_start..].find('"')? + value_start;
Some(&serde_line[value_start..end])
}
#[test]
fn beta_operations_match_spec() {
assert_beta_operations_match(&serde_json::from_str(SPEC_JSON).unwrap());
}
#[tokio::test]
#[ignore = "hits the live published ClickHouse OpenAPI spec"]
async fn beta_operations_match_live_spec() {
let spec = load_live_spec().await;
assert_beta_operations_match(&spec);
}
fn assert_beta_operations_match(spec: &Value) {
let spec_beta: BTreeSet<String> = spec_beta_operation_ids(spec);
let declared: BTreeSet<String> =
BETA_OPERATIONS.iter().map(|s| (*s).to_string()).collect();
let missing: Vec<_> = spec_beta.difference(&declared).cloned().collect();
let extra: Vec<_> = declared.difference(&spec_beta).cloned().collect();
assert!(
missing.is_empty() && extra.is_empty(),
"BETA_OPERATIONS drifted from the OpenAPI spec.\n\
New beta ops in spec, missing from meta.rs: {:?}\n\
No longer beta in spec, still in meta.rs: {:?}\n\
Regenerate with: python3 scripts/regenerate-beta-lists.py",
missing,
extra,
);
}
fn spec_beta_operation_ids(spec: &Value) -> BTreeSet<String> {
let mut ids = BTreeSet::new();
for path_item in spec["paths"].as_object().unwrap().values() {
for (method, operation) in path_item.as_object().unwrap() {
if !matches!(
method.as_str(),
"get" | "put" | "post" | "delete" | "patch" | "options" | "head" | "trace"
) {
continue;
}
let Some(badges) = operation.get("x-badges").and_then(Value::as_array) else {
continue;
};
let is_beta = badges
.iter()
.any(|b| b.get("name").and_then(Value::as_str) == Some("Beta"));
if is_beta {
ids.insert(camel_to_snake(
operation["operationId"].as_str().unwrap(),
));
}
}
}
ids
}
const DEPRECATED_FIELD_EXEMPTIONS: &[(&str, &str)] = &[];
#[test]
fn deprecated_fields_match_spec() {
assert_deprecated_fields_match(&serde_json::from_str(SPEC_JSON).unwrap());
}
#[tokio::test]
#[ignore = "hits the live published ClickHouse OpenAPI spec"]
async fn deprecated_fields_match_live_spec() {
let spec = load_live_spec().await;
assert_deprecated_fields_match(&spec);
}
#[test]
fn deprecated_fields_hidden() {
let marked = model_deprecated_marked_fields(MODELS_RS);
let declared: BTreeSet<(String, String)> = DEPRECATED_FIELDS
.iter()
.map(|(s, f)| (s.to_string(), f.to_string()))
.collect();
let missing_markers: Vec<_> = declared
.difference(&marked)
.map(|(s, f)| format!("{}.{}", s, f))
.collect();
let stray_markers: Vec<_> = marked
.difference(&declared)
.map(|(s, f)| format!("{}.{}", s, f))
.collect();
assert!(
missing_markers.is_empty() && stray_markers.is_empty(),
"DEPRECATED_FIELDS is out of sync with the #[cfg(feature = \"deprecated-fields\")] markers in models.rs.\n\
Declared but not marked (add the #[cfg(feature = \"deprecated-fields\")] marker): {:?}\n\
Marked but not declared (add to DEPRECATED_FIELDS or remove the marker): {:?}",
missing_markers,
stray_markers,
);
}
fn assert_deprecated_fields_match(spec: &Value) {
let exemptions: BTreeSet<(&str, &str)> = DEPRECATED_FIELD_EXEMPTIONS.iter().copied().collect();
let spec_fields = spec_deprecated_fields(spec);
let stale: Vec<_> = exemptions
.iter()
.filter(|(s, f)| !spec_fields.contains(&((*s).to_string(), (*f).to_string())))
.map(|(s, f)| format!("({}, {})", s, f))
.collect();
assert!(
stale.is_empty(),
"Stale DEPRECATED_FIELD_EXEMPTIONS (no longer a deprecated field):\n{}",
stale.join("\n")
);
let expected: BTreeSet<(String, String)> = spec_fields
.into_iter()
.filter(|(s, f)| !exemptions.contains(&(s.as_str(), f.as_str())))
.collect();
let declared: BTreeSet<(String, String)> = DEPRECATED_FIELDS
.iter()
.map(|(s, f)| (s.to_string(), f.to_string()))
.collect();
let missing: Vec<_> = expected
.difference(&declared)
.map(|(s, f)| format!("{}.{}", s, f))
.collect();
let extra: Vec<_> = declared
.difference(&expected)
.map(|(s, f)| format!("{}.{}", s, f))
.collect();
assert!(
missing.is_empty() && extra.is_empty(),
"DEPRECATED_FIELDS drifted from the OpenAPI spec.\n\
New deprecated fields in spec, missing from meta.rs: {:?}\n\
No longer deprecated, still in meta.rs: {:?}\n\
Regenerate with: python3 scripts/regenerate-deprecated-fields.py",
missing,
extra,
);
}
fn spec_deprecated_fields(spec: &Value) -> BTreeSet<(String, String)> {
let mut out = BTreeSet::new();
let schemas = spec["components"]["schemas"].as_object().unwrap();
for (spec_name, schema) in schemas {
let Some(props) = schema.get("properties").and_then(Value::as_object) else {
continue;
};
for (prop_name, prop) in props {
if prop.get("deprecated").and_then(Value::as_bool) == Some(true) {
out.insert((pascalize_identifier(spec_name), prop_name.clone()));
}
}
}
out
}
fn model_deprecated_marked_fields(source: &str) -> BTreeSet<(String, String)> {
let mut out = BTreeSet::new();
for (struct_name, fields) in parse_model_fields(source) {
for (spec_field, info) in fields {
if info.deprecated_marker {
out.insert((struct_name.clone(), spec_field));
}
}
}
out
}