Skip to content

Commit a4a2617

Browse files
committed
feat: add --schema-dir flag and 2020-12 support to cargo-typify CLI
The CLI now: - Auto-discovers external schema files from the input file's directory (or from --schema-dir if specified) and bundles them automatically - Uses add_schema_from_value() for automatic 2020-12 normalization - Supports non-$defs internal refs without panicking New flag: --schema-dir <DIR> Directory to search for external schemas referenced via $ref (defaults to input file dir) Usage: cargo typify schema.json # auto-discovers externals cargo typify schema.json --schema-dir ./schemas # explicit directory
1 parent 3cfdc41 commit a4a2617

8 files changed

Lines changed: 215 additions & 119 deletions

File tree

cargo-typify/src/lib.rs

Lines changed: 98 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,11 @@ pub struct CliArgs {
6161
value_parser = ["generate", "allow", "deny"]
6262
)]
6363
unknown_crates: Option<String>,
64+
65+
/// Directory to search for external schemas referenced via `$ref`.
66+
/// If not specified, the input file's parent directory is used.
67+
#[arg(long = "schema-dir")]
68+
schema_dir: Option<PathBuf>,
6469
}
6570

6671
impl CliArgs {
@@ -140,8 +145,8 @@ pub fn convert(args: &CliArgs) -> Result<String> {
140145
let content = std::fs::read_to_string(&args.input)
141146
.wrap_err_with(|| format!("Failed to open input file: {}", &args.input.display()))?;
142147

143-
let schema = serde_json::from_str::<schemars::schema::RootSchema>(&content)
144-
.wrap_err("Failed to parse input file as JSON Schema")?;
148+
let schema_value: serde_json::Value =
149+
serde_json::from_str(&content).wrap_err("Failed to parse input file as JSON")?;
145150

146151
let mut settings = TypeSpaceSettings::default();
147152
settings.with_struct_builder(args.use_builder());
@@ -178,9 +183,21 @@ pub fn convert(args: &CliArgs) -> Result<String> {
178183
}
179184

180185
let mut type_space = TypeSpace::new(&settings);
181-
type_space
182-
.add_root_schema(schema)
183-
.wrap_err("Schema conversion failed")?;
186+
187+
// Discover external schemas from the input file's directory
188+
let external_schemas = discover_external_schemas(args, &schema_value)?;
189+
190+
if external_schemas.is_empty() {
191+
// No external refs — use add_schema_from_value which handles
192+
// 2020-12 normalization and non-$defs internal refs automatically.
193+
type_space
194+
.add_schema_from_value(schema_value)
195+
.wrap_err("Schema conversion failed")?;
196+
} else {
197+
type_space
198+
.add_schema_with_externals(schema_value, external_schemas)
199+
.wrap_err("Schema conversion failed")?;
200+
}
184201

185202
let intro = "#![allow(clippy::redundant_closure_call)]
186203
#![allow(clippy::needless_lifetimes)]
@@ -195,6 +212,75 @@ pub fn convert(args: &CliArgs) -> Result<String> {
195212
Ok(contents)
196213
}
197214

215+
/// Discover external schema files referenced by the input schema.
216+
/// Looks for external `$ref` values and loads the referenced files
217+
/// from the schema directory.
218+
fn discover_external_schemas(
219+
args: &CliArgs,
220+
schema_value: &serde_json::Value,
221+
) -> Result<std::collections::BTreeMap<String, serde_json::Value>> {
222+
let schema_dir = args
223+
.schema_dir
224+
.clone()
225+
.or_else(|| args.input.parent().map(|p| p.to_path_buf()))
226+
.unwrap_or_else(|| PathBuf::from("."));
227+
228+
let refs = collect_external_refs(schema_value);
229+
let mut externals = std::collections::BTreeMap::new();
230+
231+
for ref_str in refs {
232+
// Extract the document URI (before #)
233+
let doc_uri = ref_str.split('#').next().unwrap_or(&ref_str).to_string();
234+
if doc_uri.is_empty() || externals.contains_key(&doc_uri) {
235+
continue;
236+
}
237+
238+
let file_path = schema_dir.join(&doc_uri);
239+
if file_path.exists() {
240+
let content = std::fs::read_to_string(&file_path).wrap_err_with(|| {
241+
format!("Failed to read external schema: {}", file_path.display())
242+
})?;
243+
let value: serde_json::Value = serde_json::from_str(&content).wrap_err_with(|| {
244+
format!(
245+
"Failed to parse external schema as JSON: {}",
246+
file_path.display()
247+
)
248+
})?;
249+
externals.insert(doc_uri, value);
250+
}
251+
}
252+
253+
Ok(externals)
254+
}
255+
256+
/// Recursively collect all external `$ref` strings from a JSON value.
257+
fn collect_external_refs(value: &serde_json::Value) -> Vec<String> {
258+
let mut refs = Vec::new();
259+
collect_external_refs_inner(value, &mut refs);
260+
refs
261+
}
262+
263+
fn collect_external_refs_inner(value: &serde_json::Value, refs: &mut Vec<String>) {
264+
match value {
265+
serde_json::Value::Object(map) => {
266+
if let Some(serde_json::Value::String(ref_str)) = map.get("$ref") {
267+
if !ref_str.starts_with('#') && !ref_str.is_empty() {
268+
refs.push(ref_str.clone());
269+
}
270+
}
271+
for v in map.values() {
272+
collect_external_refs_inner(v, refs);
273+
}
274+
}
275+
serde_json::Value::Array(arr) => {
276+
for item in arr {
277+
collect_external_refs_inner(item, refs);
278+
}
279+
}
280+
_ => {}
281+
}
282+
}
283+
198284
#[cfg(test)]
199285
mod tests {
200286
use super::*;
@@ -211,6 +297,7 @@ mod tests {
211297
crates: vec![],
212298
map_type: None,
213299
unknown_crates: Default::default(),
300+
schema_dir: None,
214301
};
215302

216303
assert_eq!(args.output_path(), None);
@@ -228,6 +315,7 @@ mod tests {
228315
crates: vec![],
229316
map_type: None,
230317
unknown_crates: Default::default(),
318+
schema_dir: None,
231319
};
232320

233321
assert_eq!(args.output_path(), Some(PathBuf::from("some_file.rs")));
@@ -245,6 +333,7 @@ mod tests {
245333
crates: vec![],
246334
map_type: None,
247335
unknown_crates: Default::default(),
336+
schema_dir: None,
248337
};
249338

250339
assert_eq!(args.output_path(), Some(PathBuf::from("input.rs")));
@@ -262,6 +351,7 @@ mod tests {
262351
crates: vec![],
263352
map_type: Some("::std::collections::BTreeMap".to_string()),
264353
unknown_crates: Default::default(),
354+
schema_dir: None,
265355
};
266356

267357
assert_eq!(
@@ -282,6 +372,7 @@ mod tests {
282372
crates: vec![],
283373
map_type: None,
284374
unknown_crates: Default::default(),
375+
schema_dir: None,
285376
};
286377

287378
assert!(args.use_builder());
@@ -299,6 +390,7 @@ mod tests {
299390
crates: vec![],
300391
map_type: None,
301392
unknown_crates: Default::default(),
393+
schema_dir: None,
302394
};
303395

304396
assert!(!args.use_builder());
@@ -316,6 +408,7 @@ mod tests {
316408
crates: vec![],
317409
map_type: None,
318410
unknown_crates: Default::default(),
411+
schema_dir: None,
319412
};
320413

321414
assert!(args.use_builder());

cargo-typify/tests/outputs/attr.rs

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,10 @@ pub mod error {
3535
#[doc = r""]
3636
#[doc = r" ```json"]
3737
#[doc = "{"]
38-
#[doc = " \"type\": \"object\","]
3938
#[doc = " \"additionalProperties\": {"]
4039
#[doc = " \"type\": \"string\""]
41-
#[doc = " }"]
40+
#[doc = " },"]
41+
#[doc = " \"type\": \"object\""]
4242
#[doc = "}"]
4343
#[doc = r" ```"]
4444
#[doc = r" </details>"]
@@ -76,20 +76,20 @@ impl ::std::convert::From<::std::collections::HashMap<::std::string::String, ::s
7676
#[doc = "{"]
7777
#[doc = " \"oneOf\": ["]
7878
#[doc = " {"]
79-
#[doc = " \"title\": \"veg\","]
8079
#[doc = " \"anyOf\": ["]
8180
#[doc = " {"]
8281
#[doc = " \"$ref\": \"#/defs/veggie\""]
8382
#[doc = " }"]
84-
#[doc = " ]"]
83+
#[doc = " ],"]
84+
#[doc = " \"title\": \"veg\""]
8585
#[doc = " },"]
8686
#[doc = " {"]
87-
#[doc = " \"title\": \"fruit\","]
8887
#[doc = " \"anyOf\": ["]
8988
#[doc = " {"]
9089
#[doc = " \"$ref\": \"#/defs/fruit\""]
9190
#[doc = " }"]
92-
#[doc = " ]"]
91+
#[doc = " ],"]
92+
#[doc = " \"title\": \"fruit\""]
9393
#[doc = " }"]
9494
#[doc = " ]"]
9595
#[doc = "}"]
@@ -118,11 +118,6 @@ impl ::std::convert::From<Fruit> for FruitOrVeg {
118118
#[doc = r""]
119119
#[doc = r" ```json"]
120120
#[doc = "{"]
121-
#[doc = " \"type\": \"object\","]
122-
#[doc = " \"required\": ["]
123-
#[doc = " \"veggieLike\","]
124-
#[doc = " \"veggieName\""]
125-
#[doc = " ],"]
126121
#[doc = " \"properties\": {"]
127122
#[doc = " \"veggieLike\": {"]
128123
#[doc = " \"description\": \"Do I like this vegetable?\","]
@@ -132,7 +127,12 @@ impl ::std::convert::From<Fruit> for FruitOrVeg {
132127
#[doc = " \"description\": \"The name of the vegetable.\","]
133128
#[doc = " \"type\": \"string\""]
134129
#[doc = " }"]
135-
#[doc = " }"]
130+
#[doc = " },"]
131+
#[doc = " \"required\": ["]
132+
#[doc = " \"veggieLike\","]
133+
#[doc = " \"veggieName\""]
134+
#[doc = " ],"]
135+
#[doc = " \"type\": \"object\""]
136136
#[doc = "}"]
137137
#[doc = r" ```"]
138138
#[doc = r" </details>"]
@@ -153,23 +153,23 @@ pub struct Veggie {
153153
#[doc = r" ```json"]
154154
#[doc = "{"]
155155
#[doc = " \"$id\": \"https://example.com/arrays.schema.json\","]
156-
#[doc = " \"title\": \"veggies\","]
157156
#[doc = " \"description\": \"A representation of a person, company, organization, or place\","]
158-
#[doc = " \"type\": \"object\","]
159157
#[doc = " \"properties\": {"]
160158
#[doc = " \"fruits\": {"]
161-
#[doc = " \"type\": \"array\","]
162159
#[doc = " \"items\": {"]
163160
#[doc = " \"type\": \"string\""]
164-
#[doc = " }"]
161+
#[doc = " },"]
162+
#[doc = " \"type\": \"array\""]
165163
#[doc = " },"]
166164
#[doc = " \"vegetables\": {"]
167-
#[doc = " \"type\": \"array\","]
168165
#[doc = " \"items\": {"]
169166
#[doc = " \"$ref\": \"#/$defs/veggie\""]
170-
#[doc = " }"]
167+
#[doc = " },"]
168+
#[doc = " \"type\": \"array\""]
171169
#[doc = " }"]
172-
#[doc = " }"]
170+
#[doc = " },"]
171+
#[doc = " \"title\": \"veggies\","]
172+
#[doc = " \"type\": \"object\""]
173173
#[doc = "}"]
174174
#[doc = r" ```"]
175175
#[doc = r" </details>"]

cargo-typify/tests/outputs/builder.rs

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,10 @@ pub mod error {
3535
#[doc = r""]
3636
#[doc = r" ```json"]
3737
#[doc = "{"]
38-
#[doc = " \"type\": \"object\","]
3938
#[doc = " \"additionalProperties\": {"]
4039
#[doc = " \"type\": \"string\""]
41-
#[doc = " }"]
40+
#[doc = " },"]
41+
#[doc = " \"type\": \"object\""]
4242
#[doc = "}"]
4343
#[doc = r" ```"]
4444
#[doc = r" </details>"]
@@ -75,20 +75,20 @@ impl ::std::convert::From<::std::collections::HashMap<::std::string::String, ::s
7575
#[doc = "{"]
7676
#[doc = " \"oneOf\": ["]
7777
#[doc = " {"]
78-
#[doc = " \"title\": \"veg\","]
7978
#[doc = " \"anyOf\": ["]
8079
#[doc = " {"]
8180
#[doc = " \"$ref\": \"#/defs/veggie\""]
8281
#[doc = " }"]
83-
#[doc = " ]"]
82+
#[doc = " ],"]
83+
#[doc = " \"title\": \"veg\""]
8484
#[doc = " },"]
8585
#[doc = " {"]
86-
#[doc = " \"title\": \"fruit\","]
8786
#[doc = " \"anyOf\": ["]
8887
#[doc = " {"]
8988
#[doc = " \"$ref\": \"#/defs/fruit\""]
9089
#[doc = " }"]
91-
#[doc = " ]"]
90+
#[doc = " ],"]
91+
#[doc = " \"title\": \"fruit\""]
9292
#[doc = " }"]
9393
#[doc = " ]"]
9494
#[doc = "}"]
@@ -116,11 +116,6 @@ impl ::std::convert::From<Fruit> for FruitOrVeg {
116116
#[doc = r""]
117117
#[doc = r" ```json"]
118118
#[doc = "{"]
119-
#[doc = " \"type\": \"object\","]
120-
#[doc = " \"required\": ["]
121-
#[doc = " \"veggieLike\","]
122-
#[doc = " \"veggieName\""]
123-
#[doc = " ],"]
124119
#[doc = " \"properties\": {"]
125120
#[doc = " \"veggieLike\": {"]
126121
#[doc = " \"description\": \"Do I like this vegetable?\","]
@@ -130,7 +125,12 @@ impl ::std::convert::From<Fruit> for FruitOrVeg {
130125
#[doc = " \"description\": \"The name of the vegetable.\","]
131126
#[doc = " \"type\": \"string\""]
132127
#[doc = " }"]
133-
#[doc = " }"]
128+
#[doc = " },"]
129+
#[doc = " \"required\": ["]
130+
#[doc = " \"veggieLike\","]
131+
#[doc = " \"veggieName\""]
132+
#[doc = " ],"]
133+
#[doc = " \"type\": \"object\""]
134134
#[doc = "}"]
135135
#[doc = r" ```"]
136136
#[doc = r" </details>"]
@@ -155,23 +155,23 @@ impl Veggie {
155155
#[doc = r" ```json"]
156156
#[doc = "{"]
157157
#[doc = " \"$id\": \"https://example.com/arrays.schema.json\","]
158-
#[doc = " \"title\": \"veggies\","]
159158
#[doc = " \"description\": \"A representation of a person, company, organization, or place\","]
160-
#[doc = " \"type\": \"object\","]
161159
#[doc = " \"properties\": {"]
162160
#[doc = " \"fruits\": {"]
163-
#[doc = " \"type\": \"array\","]
164161
#[doc = " \"items\": {"]
165162
#[doc = " \"type\": \"string\""]
166-
#[doc = " }"]
163+
#[doc = " },"]
164+
#[doc = " \"type\": \"array\""]
167165
#[doc = " },"]
168166
#[doc = " \"vegetables\": {"]
169-
#[doc = " \"type\": \"array\","]
170167
#[doc = " \"items\": {"]
171168
#[doc = " \"$ref\": \"#/$defs/veggie\""]
172-
#[doc = " }"]
169+
#[doc = " },"]
170+
#[doc = " \"type\": \"array\""]
173171
#[doc = " }"]
174-
#[doc = " }"]
172+
#[doc = " },"]
173+
#[doc = " \"title\": \"veggies\","]
174+
#[doc = " \"type\": \"object\""]
175175
#[doc = "}"]
176176
#[doc = r" ```"]
177177
#[doc = r" </details>"]

0 commit comments

Comments
 (0)