Skip to main content

gproxy_protocol/transform/generate/gemini_schema/
from_json.rs

1use crate::{
2    transform::{Converted, Report, TransformError},
3    wire::gemini::content::{Schema, SchemaType},
4};
5use serde_json::{Map, Value};
6
7/// Convert the exact typed Gemini subset. Unknown validation keywords fail;
8/// callers can choose an endpoint's raw JSON Schema field where supported.
9pub fn from_json(input: &Map<String, Value>) -> Result<Converted<Schema>, TransformError> {
10    let mut report = Report::default();
11    let value = convert(input, "schema", &mut report)?;
12    Ok(Converted { value, report })
13}
14
15fn convert(
16    input: &Map<String, Value>,
17    path: &str,
18    report: &mut Report,
19) -> Result<Schema, TransformError> {
20    let (kind, nullable) = match input.get("type") {
21        Some(Value::String(name)) => (schema_type(name, path)?, false),
22        Some(Value::Array(names)) => {
23            if names.len() != 2 || !names.iter().any(|name| name == "null") {
24                return Err(TransformError::unsupported(
25                    format!("{path}.type"),
26                    "typed Gemini supports only a single type with optional null",
27                ));
28            }
29            let name = names
30                .iter()
31                .find(|name| *name != "null")
32                .and_then(Value::as_str)
33                .ok_or_else(|| TransformError::shape(path, "invalid type union"))?;
34            (schema_type(name, path)?, true)
35        }
36        None if input.contains_key("anyOf") => (SchemaType::Unspecified, false),
37        None => {
38            return Err(TransformError::unsupported(
39                format!("{path}.type"),
40                "Gemini typed schema requires a type or anyOf",
41            ));
42        }
43        Some(_) => {
44            return Err(TransformError::shape(
45                format!("{path}.type"),
46                "invalid JSON Schema type",
47            ));
48        }
49    };
50    let mut out = Schema::builder(kind).build();
51    if nullable {
52        out.nullable = Some(true);
53    }
54    for (key, value) in input {
55        let field = format!("{path}.{key}");
56        match key.as_str() {
57            "type" => {}
58            "format" => out.format = Some(decode(value, &field)?),
59            "title" => out.title = Some(decode(value, &field)?),
60            "description" => out.description = Some(decode(value, &field)?),
61            "pattern" => out.pattern = Some(decode(value, &field)?),
62            "required" => out.required = Some(decode(value, &field)?),
63            "enum" => {
64                if out.type_ != SchemaType::String {
65                    continue;
66                }
67                let values: Vec<String> = decode(value, &field)?;
68                if values.is_empty() {
69                    return Err(TransformError::shape(&field, "enum must be nonempty"));
70                }
71                out.r#enum = Some(values);
72            }
73            "minItems" => out.min_items = Some(bound(value, &field)?),
74            "maxItems" => out.max_items = Some(bound(value, &field)?),
75            "minLength" => out.min_length = Some(bound(value, &field)?),
76            "maxLength" => out.max_length = Some(bound(value, &field)?),
77            "minProperties" => out.min_properties = Some(bound(value, &field)?),
78            "maxProperties" => out.max_properties = Some(bound(value, &field)?),
79            "minimum" => out.minimum = Some(number(value, &field)?),
80            "maximum" => out.maximum = Some(number(value, &field)?),
81            "default" => out.default = Some(value.clone()),
82            "examples" => {
83                let examples = value
84                    .as_array()
85                    .ok_or_else(|| TransformError::shape(&field, "examples must be an array"))?;
86                out.example = examples.first().cloned();
87                if examples.len() > 1 {
88                    report.omitted(field, "Gemini carries only the first example");
89                }
90            }
91            "properties" => {
92                let values = object(value, &field)?;
93                let mut properties = std::collections::BTreeMap::new();
94                for (name, value) in values {
95                    let child = format!("{field}.{name}");
96                    properties.insert(
97                        name.clone(),
98                        Box::new(convert(object(value, &child)?, &child, report)?),
99                    );
100                }
101                out.properties = Some(properties);
102            }
103            "items" => out.items = Some(Box::new(convert(object(value, &field)?, &field, report)?)),
104            "anyOf" => {
105                let values = value.as_array().filter(|v| !v.is_empty()).ok_or_else(|| {
106                    TransformError::shape(&field, "anyOf must be a nonempty array")
107                })?;
108                let mut variants = Vec::with_capacity(values.len());
109                for (index, value) in values.iter().enumerate() {
110                    let child = format!("{field}[{index}]");
111                    variants.push(Box::new(convert(object(value, &child)?, &child, report)?));
112                }
113                out.any_of = Some(variants);
114            }
115            _ => {
116                continue;
117            }
118        }
119    }
120    Ok(out)
121}
122
123fn schema_type(name: &str, path: &str) -> Result<SchemaType, TransformError> {
124    Ok(match name {
125        "string" => SchemaType::String,
126        "number" => SchemaType::Number,
127        "integer" => SchemaType::Integer,
128        "boolean" => SchemaType::Boolean,
129        "array" => SchemaType::Array,
130        "object" => SchemaType::Object,
131        "null" => SchemaType::Null,
132        _ => {
133            return Err(TransformError::shape(
134                format!("{path}.type"),
135                "unknown JSON Schema type",
136            ));
137        }
138    })
139}
140
141fn object<'a>(value: &'a Value, path: &str) -> Result<&'a Map<String, Value>, TransformError> {
142    value
143        .as_object()
144        .ok_or_else(|| TransformError::unsupported(path, "typed Gemini requires an object schema"))
145}
146
147fn decode<T: serde::de::DeserializeOwned>(value: &Value, path: &str) -> Result<T, TransformError> {
148    serde_json::from_value(value.clone())
149        .map_err(|error| TransformError::shape(path, error.to_string()))
150}
151
152fn bound(value: &Value, path: &str) -> Result<String, TransformError> {
153    let integer = value.as_i64().filter(|n| *n >= 0).or_else(|| {
154        let n = value.as_f64()?;
155        (n.is_finite() && (0.0..9_223_372_036_854_775_808.0).contains(&n) && n.fract() == 0.0)
156            .then_some(n as i64)
157    });
158    integer
159        .map(|n| n.to_string())
160        .ok_or_else(|| TransformError::shape(path, "expected a nonnegative int64 bound"))
161}
162
163fn number(value: &Value, path: &str) -> Result<f64, TransformError> {
164    let n = value
165        .as_f64()
166        .filter(|value| value.is_finite())
167        .ok_or_else(|| TransformError::shape(path, "expected a number"))?;
168    // Reject integer values whose constraint would change through f64 rounding.
169    if value.as_i64().is_some_and(|v| n as i128 != i128::from(v))
170        || value.as_u64().is_some_and(|v| n as i128 != i128::from(v))
171    {
172        return Err(TransformError::unsupported(
173            path,
174            "numeric bound cannot be represented exactly by Gemini double",
175        ));
176    }
177    Ok(n)
178}