Developer tools

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

#json#api#code#validation#javascript

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) → str

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.

json_types_schema(samples: list, title: str) → any

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.

json_types_rust(samples: list, name: str) → str

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.

json_types_file(path: str, name: str, lang: str) → stror an error

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.

Get it

$ quillo install json_types               # json_types.qwer into this folder$ quillo install json_types --example     # and its example, json_types.qwe

Or 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.jsonl

Then, in your program, all of it or only what you call:

main.qwe
use json_types.qweruse json_types_typescript, json_types_schema from json_types      // Quillo 0.6: only these

It uses this crate, which cargo fetches the first time the plugin is built:

[dependencies]
serde_json = { version = "1", features = ["preserve_order"] }

Example

json_types.qwe
// 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

json_types.qwer
//! 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)),    })}
Thread
77Useful? Like it, or add to its thread below.

Thread 0 posts

On JSON types: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.