JSON types
Types inferred from JSON samples: TypeScript interfaces, a JSON Schema and Rust structs for an API's answers or a webhook's body, with optional and nullable fields found across samples.
Version 1.0.0 · 4 functions · 1 crate
Functions
What use json_types.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use json_types_typescript, json_types_schema from json_types (Quillo 0.6).
json_types_typescript(samples: list, name: str) → strTypeScript for the samples (each a whole value: an API answer, a webhook body), the root named name: an interface per kind of object, named from its key (items in lines are Line), fields missing from some samples marked ?, null seen as | null.
json_types_schema(samples: list, title: str) → anyA JSON Schema (2020-12) the samples all meet, titled title: types (integer when every number was whole), required from the fields every sample had, format for dates, date-times, emails, URIs and UUIDs. A start for validating what comes in; tighten it by hand.
json_types_rust(samples: list, name: str) → strRust structs with serde's derives for the samples, the root named name: what a .qwer plugin or a Rust service would serde_json::from_value an API's answer into. Fields missing from some samples, or null, are Option; keys that are not snake case keep their name with #[serde(rename)]; values of mixed kinds are serde_json::Value.
json_types_file(path: str, name: str, lang: str) → stror an errorThe types for the samples in a file, in lang "typescript", "schema" (JSON Schema, as text) or "rust": a JSON file holding one sample, or JSON lines (one sample a line), as an API's answers are saved. Fields come in the order the file has them.
Get it
$ quillo install json_types # json_types.qwer into this folder$ quillo install json_types --example # and its example, json_types.qweOr with curl:
$ curl -O https://quillolang.org/plugins/files/json_types.qwer$ curl -O https://quillolang.org/plugins/files/json_types.qwe$ curl -O https://quillolang.org/plugins/files/json_types_sample.jsonlThen, in your program, all of it or only what you call:
use json_types.qweruse json_types_typescript, json_types_schema from json_types // Quillo 0.6: only theseIt uses this crate, which cargo fetches the first time the plugin is built:
serde_json = { version = "1", features = ["preserve_order"] }Example
// Types for a shop's webhook from a few saved bodies: TypeScript for the front end, a JSON// Schema to check what comes in, and Rust structs for a plugin.//// quillo run json_types.qwe then open http://localhost:8080/typescriptuse "json_types.qwer"let text = (s) => respond(200, s, { "content-type": "text/plain; charset=utf-8" })route GET /typescript = text(json_types_file("json_types_sample.jsonl", "OrderEvent", "typescript")?)route GET /schema = text(json_types_file("json_types_sample.jsonl", "OrderEvent", "schema")?)route GET /rust = text(json_types_file("json_types_sample.jsonl", "OrderEvent", "rust")?)// Samples from the program itself.route GET /inline = { ts: json_types_typescript([{ id: 1, tags: ["a"], price: 9.5 }, { id: 2, tags: [], price: 10, note: "gift" }], "Product"), schema: json_types_schema([{ id: 1, at: "2026-10-29", url: "https://shop.example/p/1" }, { id: 2, at: "2026-10-30", url: null }], "Row"),}$ quillo run json_types.qwe$ curl 'localhost:8080/typescript'$ curl 'localhost:8080/schema'$ curl 'localhost:8080/rust'Source
//! Types inferred from JSON samples: TypeScript interfaces, a JSON Schema and Rust structs for an API's answers or a webhook's body, optional and nullable fields found from several samples.//! version: 1.0.0//!//! [dependencies]//! serde_json = { version = "1", features = ["preserve_order"] }use serde_json::{json, Map, Value};#[derive(Clone, Default)]struct T { seen: usize, null: bool, boolean: bool, int: bool, float: bool, string: bool, format: Option<Option<&'static str>>, array: Option<Box<T>>, object: Option<Obj>,}#[derive(Clone, Default)]struct Obj { seen: usize, fields: Vec<(String, T)>,}fn format_of(s: &str) -> Option<&'static str> { let b = s.as_bytes(); let digits = |r: std::ops::Range<usize>| b.get(r.clone()).is_some_and(|x| x.iter().all(u8::is_ascii_digit)); if b.len() >= 20 && digits(0..4) && b[4] == b'-' && digits(5..7) && b[7] == b'-' && digits(8..10) && (b[10] == b'T' || b[10] == b' ') && digits(11..13) && b[13] == b':' { Some("date-time") } else if b.len() == 10 && digits(0..4) && b[4] == b'-' && digits(5..7) && b[7] == b'-' && digits(8..10) { Some("date") } else if b.len() == 36 && s.chars().enumerate().all(|(i, c)| if [8, 13, 18, 23].contains(&i) { c == '-' } else { c.is_ascii_hexdigit() }) { Some("uuid") } else if s.starts_with("https://") || s.starts_with("http://") { Some("uri") } else if s.split_once('@').is_some_and(|(a, d)| !a.is_empty() && !a.contains(' ') && d.contains('.') && !d.contains(' ') && !d.contains('@')) { Some("email") } else { None }}fn add(t: &mut T, v: &Value) { t.seen += 1; match v { Value::Null => t.null = true, Value::Bool(_) => t.boolean = true, Value::Number(n) => { if n.is_i64() || n.is_u64() { t.int = true } else { t.float = true } } Value::String(s) => { t.string = true; let f = format_of(s); t.format = Some(match t.format { None => f, Some(prev) if prev == f => f, _ => None, }); } Value::Array(xs) => { let a = t.array.get_or_insert_with(Box::default); for x in xs { add(a, x); } } Value::Object(o) => { let ob = t.object.get_or_insert_with(Obj::default); ob.seen += 1; for (k, x) in o { let i = match ob.fields.iter().position(|(n, _)| n == k) { Some(i) => i, None => { ob.fields.push((k.clone(), T::default())); ob.fields.len() - 1 } }; add(&mut ob.fields[i].1, x); } } }}fn infer(samples: &[Value]) -> T { let mut t = T::default(); for s in samples { add(&mut t, s); } t}fn pascal(s: &str) -> String { let mut out = String::new(); let mut up = true; for c in s.chars() { if c.is_alphanumeric() { if up { out.extend(c.to_uppercase()) } else { out.push(c) } up = false; } else { up = true; } } if out.is_empty() || out.starts_with(|c: char| c.is_ascii_digit()) { format!("T{}", out) } else { out }}fn singular(s: &str) -> String { if let Some(b) = s.strip_suffix("ies") { format!("{}y", b) } else if let Some(b) = s.strip_suffix("sses").or_else(|| s.strip_suffix("xes")).or_else(|| s.strip_suffix("ches")).or_else(|| s.strip_suffix("shes")) { format!("{}{}", b, &s[b.len()..s.len() - 2]) } else if s.ends_with('s') && !s.ends_with("ss") && s.len() > 1 { s[..s.len() - 1].to_string() } else { format!("{}Item", s) }}struct Names(Vec<String>);impl Names { fn take(&mut self, want: String) -> String { let mut n = want.clone(); let mut i = 2; while self.0.contains(&n) { n = format!("{}{}", want, i); i += 1; } self.0.push(n.clone()); n }}// ---- TypeScript ----fn ts_key(k: &str) -> String { if !k.is_empty() && !k.starts_with(|c: char| c.is_ascii_digit()) && k.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$') { k.to_string() } else { serde_json::to_string(k).unwrap() }}fn ts_type(t: &T, name: &str, names: &mut Names, out: &mut Vec<String>) -> String { let mut parts: Vec<String> = vec![]; if t.string { parts.push("string".into()) } if t.int || t.float { parts.push("number".into()) } if t.boolean { parts.push("boolean".into()) } if let Some(o) = &t.object { let n = names.take(pascal(name)); ts_interface(o, &n, names, out); parts.push(n); } if let Some(a) = &t.array { let inner = if a.seen == 0 { "unknown".to_string() } else { ts_type(a, &singular(name), names, out) }; parts.push(if inner.contains(' ') { format!("({})[]", inner) } else { format!("{}[]", inner) }); } if t.null { parts.push("null".into()) } if parts.is_empty() { "unknown".into() } else { parts.join(" | ") }}fn ts_interface(o: &Obj, name: &str, names: &mut Names, out: &mut Vec<String>) { let mut body = format!("export interface {} {{\n", name); let mut nested = vec![]; for (k, f) in &o.fields { let ty = ts_type(f, k, names, &mut nested); let opt = if f.seen < o.seen { "?" } else { "" }; body.push_str(&format!(" {}{}: {};\n", ts_key(k), opt, ty)); } body.push('}'); out.push(body); out.extend(nested);}/// TypeScript for the samples (each a whole value: an API answer, a webhook body), the root named `name`: an interface per kind of/// object, named from its key (items in `lines` are `Line`), fields missing from some samples marked `?`, null seen as `| null`.pub fn json_types_typescript(samples: Vec<serde_json::Value>, name: &str) -> String { let t = infer(&samples); let mut names = Names(vec![]); let mut out = vec![]; let root = ts_type(&t, name, &mut names, &mut out); if t.object.is_none() || root != pascal(name) { out.insert(0, format!("export type {} = {};", names.take(pascal(name)), root)); } out.join("\n\n") + "\n"}// ---- JSON Schema ----fn schema(t: &T) -> Value { let mut alts: Vec<Value> = vec![]; let mut simple: Vec<&str> = vec![]; if t.string { simple.push("string") } if t.int && !t.float { simple.push("integer") } if t.float { simple.push("number") } if t.boolean { simple.push("boolean") } if t.null { simple.push("null") } if let Some(o) = &t.object { let mut props = Map::new(); let mut req = vec![]; for (k, f) in &o.fields { props.insert(k.clone(), schema(f)); if f.seen == o.seen { req.push(json!(k)); } } alts.push(json!({ "type": "object", "properties": props, "required": req })); } if let Some(a) = &t.array { alts.push(if a.seen == 0 { json!({ "type": "array" }) } else { json!({ "type": "array", "items": schema(a) }) }); } let fmt = if t.string && simple.len() - usize::from(t.null) == 1 { t.format.flatten() } else { None }; let simple_schema = || { let mut m = Map::new(); m.insert("type".into(), if simple.len() == 1 { json!(simple[0]) } else { json!(simple) }); if let Some(f) = fmt { m.insert("format".into(), json!(f)); } Value::Object(m) }; match (alts.len(), simple.len()) { (0, 0) => json!({}), (0, _) => simple_schema(), (1, 0) => alts.pop().unwrap(), (1, _) if simple == ["null"] => { let mut a = alts.pop().unwrap(); a["type"] = json!([a["type"].clone(), json!("null")]); a } _ => { if !simple.is_empty() { alts.push(simple_schema()); } json!({ "anyOf": alts }) } }}/// A JSON Schema (2020-12) the samples all meet, titled `title`: types (integer when every number was whole), `required` from the/// fields every sample had, `format` for dates, date-times, emails, URIs and UUIDs. A start for validating what comes in; tighten it by hand.pub fn json_types_schema(samples: Vec<serde_json::Value>, title: &str) -> Value { let mut s = schema(&infer(&samples)); if let Value::Object(m) = &mut s { let mut out = Map::new(); out.insert("$schema".into(), json!("https://json-schema.org/draft/2020-12/schema")); if !title.is_empty() { out.insert("title".into(), json!(title)); } for (k, v) in m.iter() { out.insert(k.clone(), v.clone()); } return Value::Object(out); } s}// ---- Rust ----fn snake(s: &str) -> String { let mut out = String::new(); let mut prev_lower = false; for c in s.chars() { if c.is_ascii_uppercase() { if prev_lower { out.push('_'); } out.push(c.to_ascii_lowercase()); prev_lower = false; } else if c.is_alphanumeric() { out.push(c); prev_lower = c.is_lowercase() || c.is_ascii_digit(); } else { if !out.ends_with('_') && !out.is_empty() { out.push('_'); } prev_lower = false; } } let out = out.trim_end_matches('_').to_string(); const KW: [&str; 12] = ["type", "match", "fn", "struct", "enum", "impl", "loop", "move", "ref", "self", "use", "where"]; if out.is_empty() || out.starts_with(|c: char| c.is_ascii_digit()) { format!("f_{}", out) } else if KW.contains(&out.as_str()) { format!("r#{}", out) } else { out }}fn rs_type(t: &T, name: &str, names: &mut Names, out: &mut Vec<String>) -> String { let kinds = [t.string, t.int || t.float, t.boolean, t.object.is_some(), t.array.is_some()].iter().filter(|x| **x).count(); let base = if kinds != 1 { "serde_json::Value".to_string() } else if t.string { "String".into() } else if t.float { "f64".into() } else if t.int { "i64".into() } else if t.boolean { "bool".into() } else if let Some(o) = &t.object { let n = names.take(pascal(name)); rs_struct(o, &n, names, out); n } else { let a = t.array.as_ref().unwrap(); format!("Vec<{}>", if a.seen == 0 { "serde_json::Value".into() } else { rs_type(a, &singular(name), names, out) }) }; if t.null && kinds == 1 { format!("Option<{}>", base) } else { base }}fn rs_struct(o: &Obj, name: &str, names: &mut Names, out: &mut Vec<String>) { let mut body = format!("#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct {} {{\n", name); let mut nested = vec![]; for (k, f) in &o.fields { let mut ty = rs_type(f, k, names, &mut nested); let field = snake(k); if field.trim_start_matches("r#") != k { body.push_str(&format!(" #[serde(rename = {})]\n", serde_json::to_string(k).unwrap())); } if f.seen < o.seen { if !ty.starts_with("Option<") { ty = format!("Option<{}>", ty); } body.push_str(" #[serde(default, skip_serializing_if = \"Option::is_none\")]\n"); } body.push_str(&format!(" pub {}: {},\n", field, ty)); } body.push('}'); out.push(body); out.extend(nested);}/// Rust structs with serde's derives for the samples, the root named `name`: what a `.qwer` plugin or a Rust service would/// `serde_json::from_value` an API's answer into. Fields missing from some samples, or null, are `Option`; keys that are not snake case/// keep their name with `#[serde(rename)]`; values of mixed kinds are `serde_json::Value`.pub fn json_types_rust(samples: Vec<serde_json::Value>, name: &str) -> String { let t = infer(&samples); let mut names = Names(vec![]); let mut out = vec![]; let root = rs_type(&t, name, &mut names, &mut out); if t.object.is_none() { out.insert(0, format!("pub type {} = {};", names.take(pascal(name)), root)); } format!("use serde::{{Deserialize, Serialize}};\n\n{}\n", out.join("\n\n"))}/// The types for the samples in a file, in `lang` "typescript", "schema" (JSON Schema, as text) or "rust": a JSON file holding one/// sample, or JSON lines (one sample a line), as an API's answers are saved. Fields come in the order the file has them.pub fn json_types_file(path: &str, name: &str, lang: &str) -> Result<String, String> { let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {}", path, e))?; let mut samples = vec![]; for (i, v) in serde_json::Deserializer::from_str(&text).into_iter::<Value>().enumerate() { samples.push(v.map_err(|e| format!("{}: sample {} is not JSON: {}", path, i + 1, e))?); } if samples.is_empty() { return Err(format!("{} holds no JSON", path)); } Ok(match lang { "typescript" | "ts" => json_types_typescript(samples, name), "schema" | "json_schema" => serde_json::to_string_pretty(&json_types_schema(samples, name)).unwrap() + "\n", "rust" => json_types_rust(samples, name), other => return Err(format!("unknown lang `{}`: typescript, schema or rust", other)), })}Related
json_schema.qwer · 1.0.0Check values against a JSON Schema (drafts 4 to 2020-12), with every error and the path of the value at fault.8 views · 8 downloadsGraphQL query checks graphql_parse.qwer · 1.0.0GraphQL queries read before they run: their operations, fields and depth, limits that refuse expensive queries, and a check against the schema.6 views · 8 downloadsjq jq.qwer · 1.0.0jq filters over JSON with jaq: pick, reshape, filter and sum API answers, webhook bodies and JSON lines in the language people already write in jq, with variables and a check for typed-in filters.7 views · 8 downloadsOpenAPI openapi.qwer · 1.0.0OpenAPI 3 documents read from JSON or YAML: each operation's path, parameters, body and responses, and a request path matched to its operation.8 views · 9 downloadsSource maps source_maps.qwer · 1.0.0Minified JavaScript stack traces mapped back to the original files, lines, columns and function names with their source maps, and the code around each place.7 views · 8 downloadsFHIR fhir.qwer · 1.0.0HL7 FHIR R4 JSON: resources checked for required fields, bound codes and date formats, Bundles read and built, references resolved across a Bundle, FHIRPath-lite field access, and search parameters with paging, sorting and includes for a FHIR server.3 views · 7 downloadsThread 0 posts
On JSON types: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.