33use serde:: { Deserialize , Serialize } ;
44use std:: collections:: BTreeMap ;
55
6- /// Schema reference or inline schema
7- #[ derive( Debug , Clone , Serialize , Deserialize ) ]
6+ /// Schema reference or inline schema.
7+ ///
8+ /// Serializes untagged — a bare `{"$ref": ...}` object for
9+ /// [`SchemaRef::Ref`], the schema object for [`SchemaRef::Inline`].
10+ ///
11+ /// Deserialization is a hand-written impl rather than
12+ /// `#[serde(untagged)]`: an untagged `Ref`-first enum greedily matched
13+ /// **any** object carrying a `$ref` key and silently dropped its
14+ /// siblings (e.g. a nullable reference's `"nullable": true`). The
15+ /// custom impl treats only a *pure* `{"$ref": <string>}` object as a
16+ /// reference; a `$ref` accompanied by any sibling keyword
17+ /// (`nullable`, `description`, …) is an inline [`Schema`], so those
18+ /// siblings survive the round-trip instead of being discarded.
19+ #[ derive( Debug , Clone , Serialize ) ]
820#[ serde( untagged) ]
921pub enum SchemaRef {
1022 /// Schema reference (e.g., "#/components/schemas/User")
@@ -13,6 +25,31 @@ pub enum SchemaRef {
1325 Inline ( Box < Schema > ) ,
1426}
1527
28+ impl < ' de > Deserialize < ' de > for SchemaRef {
29+ fn deserialize < D > ( deserializer : D ) -> Result < Self , D :: Error >
30+ where
31+ D : serde:: Deserializer < ' de > ,
32+ {
33+ use serde:: de:: Error as _;
34+ // OpenAPI is always JSON; buffer the node so a *pure* reference
35+ // can be distinguished from a `$ref` carrying sibling keywords.
36+ let value = serde_json:: Value :: deserialize ( deserializer) ?;
37+ // Pure reference: an object whose ONLY key is `$ref` with a string
38+ // value. A `$ref` with any sibling (`nullable`, `description`, …)
39+ // is an inline schema, so the siblings are preserved instead of
40+ // being dropped by the prior untagged `Ref`-first match.
41+ if let serde_json:: Value :: Object ( map) = & value
42+ && map. len ( ) == 1
43+ && let Some ( serde_json:: Value :: String ( ref_path) ) = map. get ( "$ref" )
44+ {
45+ return Ok ( Self :: Ref ( Reference :: new ( ref_path. clone ( ) ) ) ) ;
46+ }
47+ serde_json:: from_value :: < Schema > ( value)
48+ . map ( |schema| Self :: Inline ( Box :: new ( schema) ) )
49+ . map_err ( D :: Error :: custom)
50+ }
51+ }
52+
1653/// Reference definition
1754#[ derive( Debug , Clone , Serialize , Deserialize ) ]
1855pub struct Reference {
@@ -274,73 +311,40 @@ pub struct Schema {
274311}
275312
276313impl Schema {
277- /// Create a new schema
314+ /// Create a new schema of the given type.
315+ ///
316+ /// Every other field starts at its [`Default`] (`None`/empty), so a newly
317+ /// added `Schema` field is auto-defaulted here instead of having to be
318+ /// appended to a ~40-field manual initializer that drifts out of sync.
278319 #[ must_use]
279- pub const fn new ( schema_type : SchemaType ) -> Self {
320+ pub fn new ( schema_type : SchemaType ) -> Self {
280321 Self {
281- ref_path : None ,
282322 schema_type : Some ( schema_type) ,
283- format : None ,
284- title : None ,
285- description : None ,
286- default : None ,
287- example : None ,
288- examples : None ,
289- minimum : None ,
290- maximum : None ,
291- exclusive_minimum : None ,
292- exclusive_maximum : None ,
293- multiple_of : None ,
294- min_length : None ,
295- max_length : None ,
296- pattern : None ,
297- items : None ,
298- prefix_items : None ,
299- min_items : None ,
300- max_items : None ,
301- unique_items : None ,
302- properties : None ,
303- required : None ,
304- additional_properties : None ,
305- min_properties : None ,
306- max_properties : None ,
307- r#enum : None ,
308- all_of : None ,
309- any_of : None ,
310- one_of : None ,
311- not : None ,
312- discriminator : None ,
313- nullable : None ,
314- read_only : None ,
315- write_only : None ,
316- external_docs : None ,
317- defs : None ,
318- dynamic_anchor : None ,
319- dynamic_ref : None ,
323+ ..Self :: default ( )
320324 }
321325 }
322326
323327 /// Create a string schema
324328 #[ must_use]
325- pub const fn string ( ) -> Self {
329+ pub fn string ( ) -> Self {
326330 Self :: new ( SchemaType :: String )
327331 }
328332
329333 /// Create an integer schema
330334 #[ must_use]
331- pub const fn integer ( ) -> Self {
335+ pub fn integer ( ) -> Self {
332336 Self :: new ( SchemaType :: Integer )
333337 }
334338
335339 /// Create a number schema
336340 #[ must_use]
337- pub const fn number ( ) -> Self {
341+ pub fn number ( ) -> Self {
338342 Self :: new ( SchemaType :: Number )
339343 }
340344
341345 /// Create a boolean schema
342346 #[ must_use]
343- pub const fn boolean ( ) -> Self {
347+ pub fn boolean ( ) -> Self {
344348 Self :: new ( SchemaType :: Boolean )
345349 }
346350
@@ -381,6 +385,26 @@ impl Schema {
381385 ..Self :: new ( SchemaType :: Object )
382386 }
383387 }
388+
389+ /// Reconstruct a [`Schema`] from a compile-time-serialized JSON spec.
390+ ///
391+ /// This is the bridge the `schema!` proc-macro uses to emit a runtime
392+ /// `Schema` value that is **identical** to the one the OpenAPI
393+ /// generator produces for the same type: the macro builds the schema
394+ /// through the shared `parse_struct_to_schema` path, serializes it to
395+ /// JSON at compile time, and emits a call to this constructor — so the
396+ /// `schema!` result can never drift from the documented component
397+ /// schema (required-by-nullability, doc descriptions,
398+ /// flatten/transparent, field constraints, `$ref` references).
399+ ///
400+ /// The input is always valid JSON (the macro just serialized it via
401+ /// `serde_json`), so a parse failure is unreachable in practice; it
402+ /// degrades to [`Schema::default`] rather than panicking inside
403+ /// generated user code.
404+ #[ must_use]
405+ pub fn from_compiled_json ( json : & str ) -> Self {
406+ serde_json:: from_str ( json) . unwrap_or_default ( )
407+ }
384408}
385409
386410/// External documentation reference
@@ -409,7 +433,7 @@ pub struct Discriminator {
409433}
410434
411435/// `OpenAPI` Components (reusable components)
412- #[ derive( Debug , Clone , Serialize , Deserialize ) ]
436+ #[ derive( Debug , Clone , Default , Serialize , Deserialize ) ]
413437#[ serde( rename_all = "camelCase" ) ]
414438pub struct Components {
415439 /// Schema definitions
@@ -691,4 +715,66 @@ mod tests {
691715 "a nullable reference must not also emit a type: {json}"
692716 ) ;
693717 }
718+
719+ // ── SchemaRef: $ref-sibling preservation ─────────────────────────
720+ //
721+ // The prior `#[serde(untagged)]` `Ref`-first enum greedily matched
722+ // ANY object with a `$ref` key and silently dropped its siblings
723+ // (e.g. a nullable reference's `"nullable": true`). The custom
724+ // `Deserialize` treats only a *pure* `{"$ref": <string>}` as a
725+ // reference; a `$ref` with any sibling becomes an inline `Schema`
726+ // so the siblings round-trip intact.
727+
728+ #[ test]
729+ fn schema_ref_pure_ref_deserializes_as_ref ( ) {
730+ let v: SchemaRef =
731+ serde_json:: from_str ( r##"{"$ref":"#/components/schemas/User"}"## ) . unwrap ( ) ;
732+ match v {
733+ SchemaRef :: Ref ( r) => assert_eq ! ( r. ref_path, "#/components/schemas/User" ) ,
734+ SchemaRef :: Inline ( _) => panic ! ( "a pure $ref must deserialize as SchemaRef::Ref" ) ,
735+ }
736+ }
737+
738+ #[ test]
739+ fn schema_ref_with_nullable_sibling_preserves_fields ( ) {
740+ let v: SchemaRef =
741+ serde_json:: from_str ( r##"{"$ref":"#/components/schemas/User","nullable":true}"## )
742+ . unwrap ( ) ;
743+ match v {
744+ SchemaRef :: Inline ( schema) => {
745+ assert_eq ! (
746+ schema. ref_path. as_deref( ) ,
747+ Some ( "#/components/schemas/User" ) ,
748+ "the $ref must survive as an inline ref_path"
749+ ) ;
750+ assert_eq ! (
751+ schema. nullable,
752+ Some ( true ) ,
753+ "the nullable sibling must not be dropped"
754+ ) ;
755+ }
756+ SchemaRef :: Ref ( _) => panic ! ( "$ref with a sibling must not be matched as a bare Ref" ) ,
757+ }
758+ }
759+
760+ #[ test]
761+ fn schema_ref_inline_object_deserializes_as_inline ( ) {
762+ let v: SchemaRef = serde_json:: from_str ( r#"{"type":"string"}"# ) . unwrap ( ) ;
763+ assert ! ( matches!( v, SchemaRef :: Inline ( _) ) ) ;
764+ }
765+
766+ #[ test]
767+ fn schema_ref_nullable_reference_roundtrips ( ) {
768+ // Build → serialize → deserialize must keep BOTH `$ref` and `nullable`.
769+ let original = Schema :: nullable_reference ( "#/components/schemas/User" . to_owned ( ) ) ;
770+ let json = serde_json:: to_string ( & SchemaRef :: Inline ( Box :: new ( original) ) ) . unwrap ( ) ;
771+ let back: SchemaRef = serde_json:: from_str ( & json) . unwrap ( ) ;
772+ match back {
773+ SchemaRef :: Inline ( s) => {
774+ assert_eq ! ( s. ref_path. as_deref( ) , Some ( "#/components/schemas/User" ) ) ;
775+ assert_eq ! ( s. nullable, Some ( true ) ) ;
776+ }
777+ SchemaRef :: Ref ( _) => panic ! ( "a nullable reference must round-trip as inline" ) ,
778+ }
779+ }
694780}
0 commit comments