use jsonschema::{Draft, Validator};
use openapiv3::{OpenAPI, Schema};
use serde_json::Value;
pub fn build_validator(schema: &Schema, spec: &OpenAPI) -> Result<Validator, String> {
let schema_json = serde_json::to_value(schema)
.map_err(|e| format!("Failed to convert OpenAPI schema to JSON: {e}"))?;
let merged = merge_components_into(schema_json, spec);
jsonschema::options()
.with_draft(Draft::Draft7)
.build(&merged)
.map_err(|e| format!("Failed to create schema validator: {e}"))
}
pub fn merge_components_into(mut schema_json: Value, spec: &OpenAPI) -> Value {
let Some(components) = &spec.components else {
return schema_json;
};
let Value::Object(ref mut map) = schema_json else {
let inner = schema_json;
let wrapper = serde_json::json!({
"allOf": [inner],
});
return merge_components_into(wrapper, spec);
};
if map.contains_key("components") {
return schema_json;
}
if let Ok(comp_json) = serde_json::to_value(components) {
map.insert("components".to_string(), comp_json);
}
schema_json
}
#[cfg(test)]
mod tests {
use super::*;
use openapiv3::{Components, ReferenceOr, SchemaData, SchemaKind, StringType, Type};
fn make_spec_with_named_schema(name: &str, schema: Schema) -> OpenAPI {
let mut components = Components::default();
components.schemas.insert(name.to_string(), ReferenceOr::Item(schema));
OpenAPI {
openapi: "3.0.0".into(),
info: Default::default(),
components: Some(components),
..Default::default()
}
}
#[test]
fn dotted_schema_ref_resolves_against_spec_context() {
let nested = Schema {
schema_data: SchemaData::default(),
schema_kind: SchemaKind::Type(Type::String(StringType::default())),
};
let spec = make_spec_with_named_schema("Foo.Bar.Baz", nested);
let body_schema_json = serde_json::json!({
"type": "object",
"properties": {
"name": {"$ref": "#/components/schemas/Foo.Bar.Baz"}
},
"required": ["name"]
});
let body_schema: Schema = serde_json::from_value(body_schema_json).unwrap();
let validator =
build_validator(&body_schema, &spec).expect("validator builds against spec context");
let good = serde_json::json!({"name": "hello"});
assert!(validator.iter_errors(&good).next().is_none());
let bad = serde_json::json!({"name": 42});
assert!(validator.iter_errors(&bad).next().is_some());
}
#[test]
fn naked_validator_fails_to_build_on_dotted_ref() {
let body_schema_json = serde_json::json!({
"type": "object",
"properties": {
"name": {"$ref": "#/components/schemas/Foo.Bar.Baz"}
}
});
let result = jsonschema::options().with_draft(Draft::Draft7).build(&body_schema_json);
let err = result.expect_err("naked validator should fail to build");
let msg = err.to_string();
assert!(
msg.contains("Foo.Bar.Baz") || msg.contains("/components/schemas/"),
"naked-validator error should reference the unresolvable pointer; got: {msg}"
);
}
#[test]
fn explicit_components_takes_precedence() {
let spec = make_spec_with_named_schema(
"X",
Schema {
schema_data: SchemaData::default(),
schema_kind: SchemaKind::Type(Type::String(StringType::default())),
},
);
let schema = serde_json::json!({
"type": "object",
"components": {"schemas": {"X": {"type": "integer"}}}
});
let merged = merge_components_into(schema.clone(), &spec);
let x = merged.get("components").and_then(|c| c.get("schemas")).and_then(|s| s.get("X"));
assert_eq!(
x.and_then(|v| v.get("type")).and_then(|v| v.as_str()),
Some("integer"),
"explicit schema components should not be clobbered"
);
}
#[test]
fn spec_without_components_is_a_noop() {
let spec = OpenAPI {
openapi: "3.0.0".into(),
info: Default::default(),
..Default::default()
};
let schema = serde_json::json!({"type": "string"});
let merged = merge_components_into(schema.clone(), &spec);
assert_eq!(merged, schema);
}
}