|
| 1 | +use std::path::Path; |
| 2 | + |
| 3 | +use serde_json::{Map, Value}; |
| 4 | + |
| 5 | +/// Converts the JSON Schema produced by schemars for `T` into sentry-options schema format. |
| 6 | +/// |
| 7 | +/// The sentry-options format has strict per-level rules about which keys are allowed: |
| 8 | +/// |
| 9 | +/// - **Root**: `version` (added here), `type`, `properties` |
| 10 | +/// - **Top-level property** (inside `properties`): `type`, `description`, `default`, `items`, |
| 11 | +/// `additionalProperties` |
| 12 | +/// - **Items** (array element schema): `type`, `properties`, `additionalProperties` |
| 13 | +/// - **Nested property** (inside an items object's `properties`): `type`, |
| 14 | +/// `additionalProperties`, `optional` |
| 15 | +/// |
| 16 | +/// `$ref` references are inlined from `$defs`. Nullable types (`"type": ["T", "null"]` or |
| 17 | +/// `"anyOf": [T, null]`) are unwrapped to the base type; fields detected as nullable are |
| 18 | +/// marked `"optional": true` at the nested-property level. |
| 19 | +pub fn generate_sentry_schema<T: schemars::JsonSchema>() -> Value { |
| 20 | + let schema = schemars::schema_for!(T); |
| 21 | + let json = serde_json::to_value(&schema).expect("schema serialization cannot fail"); |
| 22 | + |
| 23 | + let defs = match json.get("$defs") { |
| 24 | + Some(Value::Object(map)) => map.clone(), |
| 25 | + _ => Map::new(), |
| 26 | + }; |
| 27 | + |
| 28 | + let converted = convert_schema(&json, &defs, Level::Root); |
| 29 | + |
| 30 | + let mut root = Map::new(); |
| 31 | + root.insert("version".to_string(), Value::String("1.0".to_string())); |
| 32 | + if let Value::Object(map) = converted { |
| 33 | + for (key, value) in map { |
| 34 | + root.insert(key, value); |
| 35 | + } |
| 36 | + } |
| 37 | + |
| 38 | + Value::Object(root) |
| 39 | +} |
| 40 | + |
| 41 | +/// Asserts that the sentry-options schema for `T` matches the golden file at `schema_path`. |
| 42 | +/// |
| 43 | +/// If the `UPDATE_SCHEMA` environment variable is set to `"1"`, the golden file is |
| 44 | +/// regenerated instead of compared. |
| 45 | +pub fn assert_schema_matches_golden_file<T: schemars::JsonSchema>(schema_path: &Path) { |
| 46 | + let generated = generate_sentry_schema::<T>(); |
| 47 | + let generated_str = |
| 48 | + serde_json::to_string_pretty(&generated).expect("schema serialization cannot fail") + "\n"; |
| 49 | + |
| 50 | + if std::env::var("UPDATE_SCHEMA").as_deref() == Ok("1") { |
| 51 | + std::fs::write(schema_path, &generated_str).expect("write schema golden file"); |
| 52 | + return; |
| 53 | + } |
| 54 | + |
| 55 | + let existing = std::fs::read_to_string(schema_path).expect("read schema golden file"); |
| 56 | + if existing != generated_str { |
| 57 | + panic!( |
| 58 | + "schema golden file is out of date:\n{}\nRun with UPDATE_SCHEMA=1 to regenerate.", |
| 59 | + render_diff(&existing, &generated_str), |
| 60 | + ); |
| 61 | + } |
| 62 | +} |
| 63 | + |
| 64 | +fn render_diff(old: &str, new: &str) -> String { |
| 65 | + use similar::{ChangeTag, TextDiff}; |
| 66 | + |
| 67 | + let diff = TextDiff::from_lines(old, new); |
| 68 | + let mut out = String::new(); |
| 69 | + |
| 70 | + for change in diff.iter_all_changes() { |
| 71 | + let (prefix, color_open, color_close) = match change.tag() { |
| 72 | + ChangeTag::Equal => (" ", "", ""), |
| 73 | + ChangeTag::Delete => ("- ", "\x1b[31m", "\x1b[0m"), |
| 74 | + ChangeTag::Insert => ("+ ", "\x1b[32m", "\x1b[0m"), |
| 75 | + }; |
| 76 | + out.push_str(&format!( |
| 77 | + "{}{}{}{}", |
| 78 | + color_open, prefix, change, color_close |
| 79 | + )); |
| 80 | + } |
| 81 | + |
| 82 | + out |
| 83 | +} |
| 84 | + |
| 85 | +// --- conversion internals --- |
| 86 | + |
| 87 | +/// Tracks which structural level of the sentry-options schema format is being produced. |
| 88 | +#[derive(Clone, Copy)] |
| 89 | +enum Level { |
| 90 | + /// The root object (`{ "version", "type", "properties" }`). |
| 91 | + Root, |
| 92 | + /// A key directly inside the root `properties` map. Allowed: `type`, `description`, |
| 93 | + /// `default`, `items`, `additionalProperties`. |
| 94 | + TopLevelProp, |
| 95 | + /// The `items` schema of an array-typed top-level property. Allowed: `type`, `properties`, |
| 96 | + /// `additionalProperties`. |
| 97 | + Items, |
| 98 | + /// A key inside the `properties` of an items object. Allowed: `type`, |
| 99 | + /// `additionalProperties`, `optional`. |
| 100 | + NestedProp, |
| 101 | +} |
| 102 | + |
| 103 | +fn convert_schema(schema: &Value, defs: &Map<String, Value>, level: Level) -> Value { |
| 104 | + match schema { |
| 105 | + Value::Object(map) => convert_object(map, defs, level), |
| 106 | + other => other.clone(), |
| 107 | + } |
| 108 | +} |
| 109 | + |
| 110 | +fn convert_object(map: &Map<String, Value>, defs: &Map<String, Value>, level: Level) -> Value { |
| 111 | + // Inline $ref references before any level-specific handling. |
| 112 | + if let Some(def_schema) = map |
| 113 | + .get("$ref") |
| 114 | + .and_then(|v| v.as_str()) |
| 115 | + .and_then(|s| s.strip_prefix("#/$defs/")) |
| 116 | + .and_then(|name| defs.get(name)) |
| 117 | + { |
| 118 | + return convert_schema(def_schema, defs, level); |
| 119 | + } |
| 120 | + |
| 121 | + match level { |
| 122 | + Level::Root => { |
| 123 | + let mut out = Map::new(); |
| 124 | + for key in ["type", "properties"] { |
| 125 | + let Some(value) = map.get(key) else { |
| 126 | + continue; |
| 127 | + }; |
| 128 | + let converted = if key == "properties" { |
| 129 | + convert_properties(value, defs, Level::TopLevelProp) |
| 130 | + } else { |
| 131 | + value.clone() |
| 132 | + }; |
| 133 | + out.insert(key.to_string(), converted); |
| 134 | + } |
| 135 | + Value::Object(out) |
| 136 | + } |
| 137 | + |
| 138 | + Level::TopLevelProp => { |
| 139 | + let mut out = Map::new(); |
| 140 | + out.insert("type".to_string(), scalar_type(map)); |
| 141 | + for key in ["description", "default", "items", "additionalProperties"] { |
| 142 | + let Some(value) = map.get(key) else { |
| 143 | + continue; |
| 144 | + }; |
| 145 | + let converted = match key { |
| 146 | + "items" => convert_schema(value, defs, Level::Items), |
| 147 | + "additionalProperties" => convert_schema(value, defs, Level::NestedProp), |
| 148 | + _ => value.clone(), |
| 149 | + }; |
| 150 | + out.insert(key.to_string(), converted); |
| 151 | + } |
| 152 | + Value::Object(out) |
| 153 | + } |
| 154 | + |
| 155 | + Level::Items => { |
| 156 | + let mut out = Map::new(); |
| 157 | + for key in ["type", "properties", "additionalProperties"] { |
| 158 | + let Some(value) = map.get(key) else { |
| 159 | + continue; |
| 160 | + }; |
| 161 | + let converted = match key { |
| 162 | + "properties" => convert_properties(value, defs, Level::NestedProp), |
| 163 | + "additionalProperties" => convert_schema(value, defs, Level::NestedProp), |
| 164 | + _ => value.clone(), |
| 165 | + }; |
| 166 | + out.insert(key.to_string(), converted); |
| 167 | + } |
| 168 | + Value::Object(out) |
| 169 | + } |
| 170 | + |
| 171 | + Level::NestedProp => { |
| 172 | + let is_optional = is_optional(map); |
| 173 | + let mut out = Map::new(); |
| 174 | + out.insert("type".to_string(), scalar_type(map)); |
| 175 | + if let Some(value) = map.get("additionalProperties") { |
| 176 | + out.insert( |
| 177 | + "additionalProperties".to_string(), |
| 178 | + convert_schema(value, defs, Level::NestedProp), |
| 179 | + ); |
| 180 | + } |
| 181 | + if is_optional { |
| 182 | + out.insert("optional".to_string(), Value::Bool(true)); |
| 183 | + } |
| 184 | + Value::Object(out) |
| 185 | + } |
| 186 | + } |
| 187 | +} |
| 188 | + |
| 189 | +/// Extracts the scalar type string, unwrapping nullable array types like `["string", "null"]`. |
| 190 | +fn scalar_type(map: &Map<String, Value>) -> Value { |
| 191 | + match map.get("type") { |
| 192 | + Some(Value::Array(types)) => { |
| 193 | + let non_null: Vec<&Value> = types |
| 194 | + .iter() |
| 195 | + .filter(|t| t.as_str() != Some("null")) |
| 196 | + .collect(); |
| 197 | + if non_null.len() == 1 { |
| 198 | + return (*non_null[0]).clone(); |
| 199 | + } |
| 200 | + Value::Array(types.clone()) |
| 201 | + } |
| 202 | + Some(t) => t.clone(), |
| 203 | + None => Value::Null, |
| 204 | + } |
| 205 | +} |
| 206 | + |
| 207 | +/// Returns `true` if the field is optional: nullable type, anyOf with null, or has a default. |
| 208 | +fn is_optional(map: &Map<String, Value>) -> bool { |
| 209 | + if map.contains_key("default") { |
| 210 | + return true; |
| 211 | + } |
| 212 | + if matches!(map.get("type"), Some(Value::Array(types)) if types.iter().any(|t| t.as_str() == Some("null"))) |
| 213 | + { |
| 214 | + return true; |
| 215 | + } |
| 216 | + if matches!( |
| 217 | + map.get("anyOf"), |
| 218 | + Some(Value::Array(variants)) |
| 219 | + if variants.iter().any(|v| v.get("type").is_some_and(|t| t == "null")) |
| 220 | + ) { |
| 221 | + return true; |
| 222 | + } |
| 223 | + false |
| 224 | +} |
| 225 | + |
| 226 | +fn convert_properties(props: &Value, defs: &Map<String, Value>, level: Level) -> Value { |
| 227 | + let Value::Object(map) = props else { |
| 228 | + return props.clone(); |
| 229 | + }; |
| 230 | + let converted = map |
| 231 | + .iter() |
| 232 | + .map(|(k, v)| (k.clone(), convert_schema(v, defs, level))) |
| 233 | + .collect(); |
| 234 | + Value::Object(converted) |
| 235 | +} |
0 commit comments