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