Skip to main content

mockforge_openapi/
schema_ref_resolver.rs

1//! Build a JSON Schema validator that can resolve nested `$ref`
2//! pointers into the OpenAPI document's `#/components/schemas/...`
3//! map.
4//!
5//! Issue #79 round 18.3 — Srikanth's vCenter run on 0.3.152
6//! produced 157 violations like
7//!   "Failed to create schema validator: Pointer
8//!    '/components/schemas/Vcenter.VM.DiskCloneSpec' does not exist"
9//!
10//! Root cause: `validate_request_body` called
11//! `jsonschema::options().build(&inner_schema_json)` with **only the
12//! inner schema** as the validator's document. When the schema's
13//! properties contained nested `"$ref": "#/components/schemas/X"`
14//! strings, the validator tried to resolve them against the inner
15//! schema, which has no `components` section.
16//!
17//! Fix: wrap the inner schema so it carries the spec's components
18//! map at the document root, giving `$ref` pointers a place to
19//! resolve to. JSON Schema validators ignore unknown root keys, so
20//! the synthetic `components` field doesn't affect validation
21//! semantics — it's only there to be a resolution target.
22
23use jsonschema::{Draft, Validator};
24use openapiv3::{OpenAPI, Schema};
25use serde_json::Value;
26
27/// Build a `jsonschema::Validator` for `schema` that can resolve
28/// `$ref` pointers against the full `spec`. Returns a `String`
29/// error so callers don't have to thread `jsonschema::ValidationError`
30/// lifetimes through their result types.
31pub fn build_validator(schema: &Schema, spec: &OpenAPI) -> Result<Validator, String> {
32    let schema_json = serde_json::to_value(schema)
33        .map_err(|e| format!("Failed to convert OpenAPI schema to JSON: {e}"))?;
34    let merged = merge_components_into(schema_json, spec);
35    jsonschema::options()
36        .with_draft(Draft::Draft7)
37        .build(&merged)
38        .map_err(|e| format!("Failed to create schema validator: {e}"))
39}
40
41/// Merge the spec's components into a root-level `components` key on
42/// the schema document. If the schema already declares a
43/// `components` key (rare but legal), it takes precedence — we don't
44/// clobber explicit data. Returns the merged document.
45pub fn merge_components_into(mut schema_json: Value, spec: &OpenAPI) -> Value {
46    let Some(components) = &spec.components else {
47        return schema_json;
48    };
49    let Value::Object(ref mut map) = schema_json else {
50        // Non-object root (rare — a schema is usually an object).
51        // Wrap it in an object so we can attach components.
52        let inner = schema_json;
53        let wrapper = serde_json::json!({
54            "allOf": [inner],
55        });
56        return merge_components_into(wrapper, spec);
57    };
58    if map.contains_key("components") {
59        return schema_json;
60    }
61    if let Ok(comp_json) = serde_json::to_value(components) {
62        map.insert("components".to_string(), comp_json);
63    }
64    schema_json
65}
66
67#[cfg(test)]
68mod tests {
69    use super::*;
70    use openapiv3::{Components, ReferenceOr, SchemaData, SchemaKind, StringType, Type};
71
72    fn make_spec_with_named_schema(name: &str, schema: Schema) -> OpenAPI {
73        let mut components = Components::default();
74        components.schemas.insert(name.to_string(), ReferenceOr::Item(schema));
75        OpenAPI {
76            openapi: "3.0.0".into(),
77            info: Default::default(),
78            components: Some(components),
79            ..Default::default()
80        }
81    }
82
83    /// The canonical bug reproducer: a request-body schema with a
84    /// nested `$ref` to a components/schemas/X with dots in the
85    /// name. Pre-fix this failed with "Pointer does not exist".
86    /// Post-fix it should build a validator and validate a body
87    /// against it cleanly.
88    #[test]
89    fn dotted_schema_ref_resolves_against_spec_context() {
90        // Define `Foo.Bar.Baz` in components — string type.
91        let nested = Schema {
92            schema_data: SchemaData::default(),
93            schema_kind: SchemaKind::Type(Type::String(StringType::default())),
94        };
95        let spec = make_spec_with_named_schema("Foo.Bar.Baz", nested);
96
97        // Request body schema: object with one property that
98        // $refs into the components.
99        let body_schema_json = serde_json::json!({
100            "type": "object",
101            "properties": {
102                "name": {"$ref": "#/components/schemas/Foo.Bar.Baz"}
103            },
104            "required": ["name"]
105        });
106        let body_schema: Schema = serde_json::from_value(body_schema_json).unwrap();
107
108        let validator =
109            build_validator(&body_schema, &spec).expect("validator builds against spec context");
110
111        // A valid body — `name` is a string.
112        let good = serde_json::json!({"name": "hello"});
113        assert!(validator.iter_errors(&good).next().is_none());
114
115        // An invalid body — `name` is a number, should be rejected.
116        let bad = serde_json::json!({"name": 42});
117        assert!(validator.iter_errors(&bad).next().is_some());
118    }
119
120    /// Pre-fix path: a validator built from just the inner schema
121    /// (without our wrapper) fails AT BUILD TIME to resolve dotted
122    /// $refs — jsonschema is eager-resolve. The error wording is
123    /// literally the one Srikanth saw:
124    /// `Pointer '/components/schemas/Foo.Bar.Baz' does not exist`.
125    /// Documenting the wrong behaviour so future-me knows what
126    /// `build_validator` is protecting against.
127    #[test]
128    fn naked_validator_fails_to_build_on_dotted_ref() {
129        let body_schema_json = serde_json::json!({
130            "type": "object",
131            "properties": {
132                "name": {"$ref": "#/components/schemas/Foo.Bar.Baz"}
133            }
134        });
135        let result = jsonschema::options().with_draft(Draft::Draft7).build(&body_schema_json);
136        let err = result.expect_err("naked validator should fail to build");
137        let msg = err.to_string();
138        assert!(
139            msg.contains("Foo.Bar.Baz") || msg.contains("/components/schemas/"),
140            "naked-validator error should reference the unresolvable pointer; got: {msg}"
141        );
142    }
143
144    /// When the schema already declares a `components` key (rare —
145    /// some specs do this inline), we don't clobber it.
146    #[test]
147    fn explicit_components_takes_precedence() {
148        let spec = make_spec_with_named_schema(
149            "X",
150            Schema {
151                schema_data: SchemaData::default(),
152                schema_kind: SchemaKind::Type(Type::String(StringType::default())),
153            },
154        );
155        let schema = serde_json::json!({
156            "type": "object",
157            "components": {"schemas": {"X": {"type": "integer"}}}
158        });
159        let merged = merge_components_into(schema.clone(), &spec);
160        // The schema's explicit `components.schemas.X` (integer)
161        // wins; the spec's (string) does not overwrite.
162        let x = merged.get("components").and_then(|c| c.get("schemas")).and_then(|s| s.get("X"));
163        assert_eq!(
164            x.and_then(|v| v.get("type")).and_then(|v| v.as_str()),
165            Some("integer"),
166            "explicit schema components should not be clobbered"
167        );
168    }
169
170    /// Specs with no `components` block at all (some Swagger
171    /// conversions, or very minimal specs) should still produce a
172    /// working validator — we just don't add a components key.
173    #[test]
174    fn spec_without_components_is_a_noop() {
175        let spec = OpenAPI {
176            openapi: "3.0.0".into(),
177            info: Default::default(),
178            ..Default::default()
179        };
180        let schema = serde_json::json!({"type": "string"});
181        let merged = merge_components_into(schema.clone(), &spec);
182        assert_eq!(merged, schema);
183    }
184}