Skip to main content

ferrin_google/
json_schema.rs

1//! Conversion of JSON Schema (draft 7) to the OpenAPI 3.0 schema subset
2//! accepted by the Gemini API (`responseSchema`, function `parameters`).
3//!
4//! Derived from the Vercel AI SDK (Apache-2.0, Copyright 2023 Vercel, Inc.),
5//! translated from TypeScript to Rust and modified; see `NOTICE`.
6
7use ferrin_spec::JsonObject;
8use ferrin_spec::JsonValue;
9use ferrin_spec::error::UnsupportedFunctionalityError;
10
11/// Prefix of the functionality of a recursive-reference error.
12pub const RECURSIVE_REFERENCE_PREFIX: &str = "recursive JSON Schema reference:";
13
14/// Whether `error` reports a recursive `$ref` (callers fall back to
15/// `parametersJsonSchema`).
16#[must_use]
17pub fn is_recursive_reference_error(error: &UnsupportedFunctionalityError) -> bool {
18    error.functionality.starts_with(RECURSIVE_REFERENCE_PREFIX)
19}
20
21struct Context<'a> {
22    definitions: Option<&'a JsonObject>,
23    dollar_definitions: Option<&'a JsonObject>,
24    resolving: Vec<String>,
25}
26
27/// Converts `schema` to an OpenAPI schema.
28///
29/// Returns `None` for `null` and for an empty root object schema (no
30/// properties, no `additionalProperties`).
31///
32/// # Errors
33///
34/// Returns [`UnsupportedFunctionalityError`] for `$ref`s that do not point at
35/// a direct child of the root `$defs`/`definitions`, for recursive `$ref`s
36/// for enums mixing value types, and for `false` schemas.
37pub fn convert_json_schema_to_openapi_schema(
38    schema: &JsonValue,
39) -> Result<Option<JsonValue>, UnsupportedFunctionalityError> {
40    let root = schema.as_object();
41    let mut context = Context {
42        definitions: root
43            .and_then(|object| object.get("definitions"))
44            .and_then(JsonValue::as_object),
45        dollar_definitions: root
46            .and_then(|object| object.get("$defs"))
47            .and_then(JsonValue::as_object),
48        resolving: Vec::new(),
49    };
50    convert_definition(schema, true, &mut context)
51}
52
53fn convert_definition(
54    schema: &JsonValue,
55    is_root: bool,
56    context: &mut Context<'_>,
57) -> Result<Option<JsonValue>, UnsupportedFunctionalityError> {
58    let object = match schema {
59        JsonValue::Null => return Ok(None),
60        JsonValue::Bool(true) => return Ok(Some(serde_json::json!({}))),
61        JsonValue::Bool(false) => {
62            return Err(UnsupportedFunctionalityError::with_message(
63                "false JSON Schema",
64                "Google OpenAPI schema conversion cannot represent a schema that rejects every value",
65            ));
66        }
67        JsonValue::Object(object) => object,
68        _ => return Ok(None),
69    };
70    if let Some(reference) = object.get("$ref").and_then(JsonValue::as_str) {
71        return convert_reference(object, reference, is_root, context);
72    }
73    if is_empty_object_schema(object) {
74        if is_root {
75            return Ok(None);
76        }
77        let mut result = JsonObject::new();
78        result.insert("type".to_owned(), JsonValue::from("object"));
79        if let Some(description) = non_empty_string(object.get("description")) {
80            result.insert("description".to_owned(), JsonValue::from(description));
81        }
82        return Ok(Some(JsonValue::Object(result)));
83    }
84    let mut result = JsonObject::new();
85    if let Some(description) = non_empty_string(object.get("description")) {
86        result.insert("description".to_owned(), JsonValue::from(description));
87    }
88    if let Some(required) = object.get("required").filter(|value| !value.is_null()) {
89        result.insert("required".to_owned(), required.clone());
90    }
91    if let Some(format) = non_empty_string(object.get("format")) {
92        result.insert("format".to_owned(), JsonValue::from(format));
93    }
94    let schema_type = object.get("type");
95    match schema_type {
96        Some(JsonValue::Array(types)) => {
97            let has_null = types.iter().any(|value| value.as_str() == Some("null"));
98            let non_null: Vec<&JsonValue> = types
99                .iter()
100                .filter(|value| value.as_str() != Some("null"))
101                .collect();
102            if non_null.is_empty() {
103                result.insert("type".to_owned(), JsonValue::from("null"));
104            } else {
105                result.insert(
106                    "anyOf".to_owned(),
107                    JsonValue::Array(
108                        non_null
109                            .iter()
110                            .map(|value| serde_json::json!({"type": (*value).clone()}))
111                            .collect(),
112                    ),
113                );
114                if has_null {
115                    result.insert("nullable".to_owned(), JsonValue::Bool(true));
116                }
117            }
118        }
119        Some(JsonValue::String(type_name)) if !type_name.is_empty() => {
120            result.insert("type".to_owned(), JsonValue::from(type_name.as_str()));
121        }
122        _ => {}
123    }
124    let values: Option<Vec<JsonValue>> = match object.get("enum") {
125        Some(JsonValue::Array(values)) => Some(values.clone()),
126        Some(_) => None,
127        None => object.get("const").map(|value| vec![value.clone()]),
128    };
129    if let Some(values) = values {
130        add_enum_to_schema(&values, schema_type, &mut result)?;
131    }
132    if let Some(JsonValue::Object(properties)) = object.get("properties") {
133        let mut converted = JsonObject::new();
134        for (key, value) in properties {
135            if let Some(schema) = convert_definition(value, false, context)? {
136                converted.insert(key.clone(), schema);
137            }
138        }
139        result.insert("properties".to_owned(), JsonValue::Object(converted));
140    }
141    match object.get("items") {
142        Some(JsonValue::Array(items)) => {
143            let converted = items
144                .iter()
145                .map(|item| convert_definition(item, false, context).map(or_null))
146                .collect::<Result<Vec<_>, _>>()?;
147            result.insert("items".to_owned(), JsonValue::Array(converted));
148        }
149        Some(items) if !items.is_null() => {
150            if let Some(converted) = convert_definition(items, false, context)? {
151                result.insert("items".to_owned(), converted);
152            }
153        }
154        _ => {}
155    }
156    if let Some(JsonValue::Array(all_of)) = object.get("allOf") {
157        result.insert(
158            "allOf".to_owned(),
159            JsonValue::Array(convert_all(all_of, context)?),
160        );
161    }
162    if let Some(JsonValue::Array(any_of)) = object.get("anyOf") {
163        let is_null_schema =
164            |schema: &JsonValue| schema.get("type").and_then(JsonValue::as_str) == Some("null");
165        if any_of.iter().any(is_null_schema) {
166            let non_null: Vec<&JsonValue> = any_of
167                .iter()
168                .filter(|schema| !is_null_schema(schema))
169                .collect();
170            if non_null.len() == 1 {
171                if let Some(JsonValue::Object(converted)) =
172                    convert_definition(non_null[0], false, context)?
173                {
174                    result.insert("nullable".to_owned(), JsonValue::Bool(true));
175                    for (key, value) in converted {
176                        result.insert(key, value);
177                    }
178                }
179            } else {
180                let converted = non_null
181                    .iter()
182                    .map(|schema| convert_definition(schema, false, context).map(or_null))
183                    .collect::<Result<Vec<_>, _>>()?;
184                result.insert("anyOf".to_owned(), JsonValue::Array(converted));
185                result.insert("nullable".to_owned(), JsonValue::Bool(true));
186            }
187        } else {
188            result.insert(
189                "anyOf".to_owned(),
190                JsonValue::Array(convert_all(any_of, context)?),
191            );
192        }
193    }
194    if let Some(JsonValue::Array(one_of)) = object.get("oneOf") {
195        result.insert(
196            "oneOf".to_owned(),
197            JsonValue::Array(convert_all(one_of, context)?),
198        );
199    }
200    for key in ["minLength", "minItems", "maxItems"] {
201        if let Some(value) = object.get(key) {
202            result.insert(key.to_owned(), value.clone());
203        }
204    }
205    Ok(Some(JsonValue::Object(result)))
206}
207
208fn convert_all(
209    schemas: &[JsonValue],
210    context: &mut Context<'_>,
211) -> Result<Vec<JsonValue>, UnsupportedFunctionalityError> {
212    schemas
213        .iter()
214        .map(|schema| convert_definition(schema, false, context).map(or_null))
215        .collect()
216}
217
218fn or_null(value: Option<JsonValue>) -> JsonValue {
219    value.unwrap_or(JsonValue::Null)
220}
221
222fn non_empty_string(value: Option<&JsonValue>) -> Option<&str> {
223    value
224        .and_then(JsonValue::as_str)
225        .filter(|text| !text.is_empty())
226}
227
228fn is_empty_object_schema(object: &JsonObject) -> bool {
229    object.get("type").and_then(JsonValue::as_str) == Some("object")
230        && object
231            .get("properties")
232            .and_then(JsonValue::as_object)
233            .is_none_or(JsonObject::is_empty)
234        && !object.get("additionalProperties").is_some_and(is_truthy)
235}
236
237fn is_truthy(value: &JsonValue) -> bool {
238    match value {
239        JsonValue::Null => false,
240        JsonValue::Bool(value) => *value,
241        JsonValue::Number(number) => number.as_f64().is_some_and(|number| number != 0.0),
242        JsonValue::String(text) => !text.is_empty(),
243        JsonValue::Array(_) | JsonValue::Object(_) => true,
244    }
245}
246
247fn convert_reference(
248    object: &JsonObject,
249    reference: &str,
250    is_root: bool,
251    context: &mut Context<'_>,
252) -> Result<Option<JsonValue>, UnsupportedFunctionalityError> {
253    let (definition, key) = referenced_definition(reference, context)?;
254    if context.resolving.iter().any(|resolving| resolving == &key) {
255        return Err(UnsupportedFunctionalityError::with_message(
256            format!("{RECURSIVE_REFERENCE_PREFIX} {reference}"),
257            "Google schema conversion does not support recursive JSON Schema references.",
258        ));
259    }
260    let mut sibling = object.clone();
261    sibling.remove("$ref");
262    let resolved = match definition {
263        JsonValue::Bool(true) => JsonValue::Object(sibling),
264        JsonValue::Bool(false) => JsonValue::Bool(false),
265        JsonValue::Object(definition) => {
266            let mut merged = definition;
267            for (key, value) in sibling {
268                merged.insert(key, value);
269            }
270            JsonValue::Object(merged)
271        }
272        other => other,
273    };
274    context.resolving.push(key);
275    let converted = convert_definition(&resolved, is_root, context);
276    context.resolving.pop();
277    converted
278}
279
280fn unsupported_reference(reference: &str) -> UnsupportedFunctionalityError {
281    UnsupportedFunctionalityError::with_message(
282        format!("JSON Schema reference: {reference}"),
283        "Google schema conversion only supports references to direct children of root-level $defs or definitions.",
284    )
285}
286
287fn referenced_definition(
288    reference: &str,
289    context: &Context<'_>,
290) -> Result<(JsonValue, String), UnsupportedFunctionalityError> {
291    let sources = [
292        ("#/$defs/", context.dollar_definitions),
293        ("#/definitions/", context.definitions),
294    ];
295    let Some((prefix, definitions)) = sources
296        .into_iter()
297        .find(|(prefix, _)| reference.starts_with(prefix))
298    else {
299        return Err(unsupported_reference(reference));
300    };
301    let encoded = &reference[prefix.len()..];
302    if encoded.is_empty() || encoded.contains('/') {
303        return Err(unsupported_reference(reference));
304    }
305    let decoded = percent_decode(encoded).ok_or_else(|| unsupported_reference(reference))?;
306    if decoded.contains('/') || has_invalid_tilde_escape(&decoded) {
307        return Err(unsupported_reference(reference));
308    }
309    let Some(definitions) = definitions else {
310        return Err(unsupported_reference(reference));
311    };
312    let name = decoded.replace("~1", "/").replace("~0", "~");
313    let Some(definition) = definitions.get(&name) else {
314        return Err(unsupported_reference(reference));
315    };
316    Ok((definition.clone(), format!("{prefix}{name}")))
317}
318
319fn has_invalid_tilde_escape(text: &str) -> bool {
320    let bytes = text.as_bytes();
321    bytes
322        .iter()
323        .enumerate()
324        .any(|(index, byte)| *byte == b'~' && !matches!(bytes.get(index + 1), Some(b'0' | b'1')))
325}
326
327fn percent_decode(text: &str) -> Option<String> {
328    if !text.contains('%') {
329        return Some(text.to_owned());
330    }
331    let bytes = text.as_bytes();
332    let mut decoded = Vec::with_capacity(bytes.len());
333    let mut index = 0;
334    while index < bytes.len() {
335        if bytes[index] == b'%' {
336            let hex = text.get(index + 1..index + 3)?;
337            decoded.push(u8::from_str_radix(hex, 16).ok()?);
338            index += 3;
339        } else {
340            decoded.push(bytes[index]);
341            index += 1;
342        }
343    }
344    String::from_utf8(decoded).ok()
345}
346
347fn type_allows(schema_type: Option<&JsonValue>, enum_type: &str) -> bool {
348    match schema_type {
349        None | Some(JsonValue::Null) => true,
350        Some(JsonValue::String(name)) => name == enum_type,
351        Some(JsonValue::Array(types)) => {
352            types.iter().any(|value| value.as_str() == Some(enum_type))
353        }
354        Some(_) => false,
355    }
356}
357
358fn enum_type(values: &[JsonValue], schema_type: Option<&JsonValue>) -> Option<&'static str> {
359    if values.is_empty() {
360        return None;
361    }
362    if type_allows(schema_type, "string") && values.iter().all(JsonValue::is_string) {
363        return Some("string");
364    }
365    let all_numbers = values
366        .iter()
367        .all(|value| value.as_f64().is_some_and(f64::is_finite));
368    if (type_allows(schema_type, "number") || type_allows(schema_type, "integer")) && all_numbers {
369        if type_allows(schema_type, "number") {
370            return Some("number");
371        }
372        if values.iter().all(|value| {
373            value.as_i64().is_some()
374                || value.as_u64().is_some()
375                || value.as_f64().is_some_and(|number| number.fract() == 0.0)
376        }) {
377            return Some("integer");
378        }
379    }
380    if type_allows(schema_type, "boolean") && values.iter().all(JsonValue::is_boolean) {
381        return Some("boolean");
382    }
383    None
384}
385
386fn add_enum_to_schema(
387    values: &[JsonValue],
388    schema_type: Option<&JsonValue>,
389    result: &mut JsonObject,
390) -> Result<(), UnsupportedFunctionalityError> {
391    let type_is_array_with_null = matches!(
392        schema_type,
393        Some(JsonValue::Array(types)) if types.iter().any(|value| value.as_str() == Some("null"))
394    );
395    let nullable = type_is_array_with_null
396        || (matches!(schema_type, None | Some(JsonValue::Null))
397            && values.iter().any(JsonValue::is_null));
398    let enum_values: Vec<JsonValue> = if nullable {
399        values
400            .iter()
401            .filter(|value| !value.is_null())
402            .cloned()
403            .collect()
404    } else {
405        values.to_vec()
406    };
407    if !values.is_empty() && values.iter().all(JsonValue::is_null) {
408        let type_allows_null = match schema_type {
409            None | Some(JsonValue::Null) => true,
410            Some(JsonValue::String(name)) => name == "null",
411            Some(JsonValue::Array(types)) => {
412                types.iter().any(|value| value.as_str() == Some("null"))
413            }
414            Some(_) => false,
415        };
416        if type_allows_null {
417            result.insert("type".to_owned(), JsonValue::from("null"));
418            if matches!(schema_type, Some(JsonValue::Array(_))) {
419                result.remove("anyOf");
420            }
421            return Ok(());
422        }
423    }
424    let Some(enum_type) = enum_type(&enum_values, schema_type) else {
425        return Err(UnsupportedFunctionalityError::with_message(
426            "JSON Schema enum with mixed or unsupported values",
427            "Google does not support this JSON Schema enum. Enum values must share one supported primitive type and match the schema type.",
428        ));
429    };
430    result.insert("type".to_owned(), JsonValue::from(enum_type));
431    if matches!(schema_type, Some(JsonValue::Array(_))) {
432        result.remove("anyOf");
433    }
434    if nullable {
435        result.insert("nullable".to_owned(), JsonValue::Bool(true));
436    }
437    if enum_type == "string" {
438        result.insert("enum".to_owned(), JsonValue::Array(enum_values));
439    } else {
440        result.insert("format".to_owned(), JsonValue::from("enum"));
441        result.insert(
442            "enum".to_owned(),
443            JsonValue::Array(
444                enum_values
445                    .iter()
446                    .map(|value| match value {
447                        JsonValue::String(text) => JsonValue::from(text.as_str()),
448                        other => JsonValue::from(other.to_string()),
449                    })
450                    .collect(),
451            ),
452        );
453    }
454    Ok(())
455}