1use ferrin_spec::JsonObject;
8use ferrin_spec::JsonValue;
9use ferrin_spec::error::UnsupportedFunctionalityError;
10
11pub const RECURSIVE_REFERENCE_PREFIX: &str = "recursive JSON Schema reference:";
13
14#[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
27pub 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}