Skip to content

Commit db98af4

Browse files
jan-auerclaude
andcommitted
feat(options): Auto-generate sentry-options schema from Rust types
Add schema generation support to objectstore-typed-options so the sentry-options schema stays in sync with the Rust struct definitions. Previously the schema was hand-written and had already drifted. The conversion logic lives in objectstore_typed_options::schema and is gated behind the testing feature. Consumers add JsonSchema derives (also test-only) and call assert_schema_matches_golden_file in a single test. The schema remains checked in so sentry-options-automator can access it without running Rust, and so reviewers can inspect schema changes in PRs. The golden file test catches drift immediately and prints a colored unified diff; UPDATE_SCHEMA=1 regenerates it. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent d48e54d commit db98af4

7 files changed

Lines changed: 308 additions & 17 deletions

File tree

Cargo.lock

Lines changed: 27 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

objectstore-options/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,5 @@ serde = { workspace = true, features = ["derive"] }
1717
testing = []
1818

1919
[dev-dependencies]
20+
objectstore-typed-options = { workspace = true, features = ["derive", "testing"] }
21+
schemars = "1.2.1"

objectstore-options/src/lib.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,11 @@ pub use objectstore_typed_options::Error;
1616
/// Obtain a snapshot of the current options via [`Options::get`]. Before calling `get`,
1717
/// the global instance must be initialized with [`Options::init`].
1818
#[derive(Debug, SentryOptions)]
19+
#[cfg_attr(test, derive(schemars::JsonSchema))]
1920
#[sentry_options(namespace = "objectstore", path = "../../sentry-options")]
2021
pub struct Options {
2122
/// Active killswitches that may disable access to specific object contexts.
23+
#[cfg_attr(test, schemars(default))]
2224
killswitches: Vec<Killswitch>,
2325
}
2426

@@ -34,6 +36,7 @@ impl Options {
3436
/// Note that at least one of the fields should be set, or else the killswitch will match all
3537
/// contexts and discard all requests.
3638
#[derive(Debug, Deserialize, Serialize, PartialEq)]
39+
#[cfg_attr(test, derive(schemars::JsonSchema))]
3740
pub struct Killswitch {
3841
/// Optional usecase to match.
3942
///
@@ -58,10 +61,21 @@ pub struct Killswitch {
5861

5962
#[cfg(test)]
6063
mod tests {
64+
use std::path::PathBuf;
65+
6166
use super::*;
6267

6368
#[test]
6469
fn schema_is_valid() {
6570
let _ = Options::get();
6671
}
72+
73+
#[test]
74+
fn schema_matches_golden_file() {
75+
let schema_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
76+
.join("../sentry-options/schemas/objectstore/schema.json");
77+
objectstore_typed_options::schema::assert_schema_matches_golden_file::<Options>(
78+
&schema_path,
79+
);
80+
}
6781
}

objectstore-typed-options/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ publish = false
1313
arc-swap = { workspace = true }
1414
objectstore-log = { workspace = true }
1515
objectstore-typed-options-derive = { workspace = true, optional = true }
16+
schemars = { version = "1.2.1", optional = true }
17+
similar = { version = "2.7.0", optional = true }
1618
sentry-options = "1.0.5"
1719
serde = { workspace = true }
1820
serde_json = { workspace = true }
@@ -21,3 +23,4 @@ tokio = { workspace = true, features = ["time"] }
2123

2224
[features]
2325
derive = ["dep:objectstore-typed-options-derive"]
26+
testing = ["dep:schemars", "dep:similar"]

objectstore-typed-options/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,9 @@
8686
//! println!("max_retries = {}", Options::get().max_retries());
8787
//! ```
8888
89+
#[cfg(feature = "testing")]
90+
pub mod schema;
91+
8992
use std::sync::{Arc, OnceLock};
9093
use std::time::Duration;
9194

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
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

Comments
 (0)