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(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}