@@ -132,7 +132,7 @@ pub struct OpenApi {
132132 pub components : Option < Components > ,
133133 /// Security requirements
134134 #[ serde( skip_serializing_if = "Option::is_none" ) ]
135- pub security : Option < Vec < HashMap < String , Vec < String > > > > ,
135+ pub security : Option < Vec < BTreeMap < String , Vec < String > > > > ,
136136 /// Tag definitions
137137 #[ serde( skip_serializing_if = "Option::is_none" ) ]
138138 pub tags : Option < Vec < Tag > > ,
@@ -141,17 +141,36 @@ pub struct OpenApi {
141141 pub external_docs : Option < ExternalDocumentation > ,
142142}
143143
144+ /// Merge `other` map entries into `self_map` with self-wins on key
145+ /// conflicts, allocating the target map only when `other` has entries.
146+ fn merge_component_map < V > (
147+ self_map : & mut Option < BTreeMap < String , V > > ,
148+ other_map : Option < BTreeMap < String , V > > ,
149+ ) {
150+ let Some ( other_map) = other_map else { return } ;
151+ let target = self_map. get_or_insert_with ( BTreeMap :: new) ;
152+ for ( name, value) in other_map {
153+ target. entry ( name) . or_insert ( value) ;
154+ }
155+ }
156+
144157impl OpenApi {
145158 /// Merge another `OpenAPI` document into this one.
146- /// Paths, schemas, and tags from `other` are added to `self`.
147- /// If there are conflicts, `self` takes precedence.
159+ ///
160+ /// All `paths`, `components` (schemas, responses, parameters,
161+ /// examples, request bodies, headers, security schemes), and `tags`
162+ /// from `other` are added to `self`. Top-level `servers`, `security`,
163+ /// and `external_docs` are adopted from `other` only when `self` has
164+ /// not set its own. On any key/field conflict, `self` takes precedence.
148165 pub fn merge ( & mut self , other : Self ) {
149166 // Merge paths (self takes precedence on conflict)
150167 for ( path, item) in other. paths {
151168 self . paths . entry ( path) . or_insert ( item) ;
152169 }
153170
154- // Merge components
171+ // Merge components (every reusable component kind, self-wins on
172+ // key conflict) — previously only `schemas` + `security_schemes`
173+ // were merged, silently dropping the rest.
155174 if let Some ( other_components) = other. components {
156175 let self_components = self . components . get_or_insert ( Components {
157176 schemas : None ,
@@ -163,23 +182,31 @@ impl OpenApi {
163182 security_schemes : None ,
164183 } ) ;
165184
166- // Merge schemas
167- if let Some ( other_schemas) = other_components. schemas {
168- let self_schemas = self_components. schemas . get_or_insert_with ( BTreeMap :: new) ;
169- for ( name, schema) in other_schemas {
170- self_schemas. entry ( name) . or_insert ( schema) ;
171- }
172- }
185+ merge_component_map ( & mut self_components. schemas , other_components. schemas ) ;
186+ merge_component_map ( & mut self_components. responses , other_components. responses ) ;
187+ merge_component_map ( & mut self_components. parameters , other_components. parameters ) ;
188+ merge_component_map ( & mut self_components. examples , other_components. examples ) ;
189+ merge_component_map (
190+ & mut self_components. request_bodies ,
191+ other_components. request_bodies ,
192+ ) ;
193+ merge_component_map ( & mut self_components. headers , other_components. headers ) ;
194+ merge_component_map (
195+ & mut self_components. security_schemes ,
196+ other_components. security_schemes ,
197+ ) ;
198+ }
173199
174- // Merge security schemes
175- if let Some ( other_security_schemes) = other_components. security_schemes {
176- let self_security_schemes = self_components
177- . security_schemes
178- . get_or_insert_with ( BTreeMap :: new) ;
179- for ( name, scheme) in other_security_schemes {
180- self_security_schemes. entry ( name) . or_insert ( scheme) ;
181- }
182- }
200+ // Merge top-level servers / security / external_docs (self wins:
201+ // adopt other's only when self has not set its own).
202+ if self . servers . is_none ( ) {
203+ self . servers = other. servers ;
204+ }
205+ if self . security . is_none ( ) {
206+ self . security = other. security ;
207+ }
208+ if self . external_docs . is_none ( ) {
209+ self . external_docs = other. external_docs ;
183210 }
184211
185212 // Merge tags (deduplicate by name). A HashSet of seen names makes
@@ -474,4 +501,115 @@ mod tests {
474501 assert ! ( base. paths. contains_key( "/users" ) ) ;
475502 assert_eq ! ( base. tags. as_ref( ) . unwrap( ) . len( ) , 1 ) ;
476503 }
504+
505+ #[ test]
506+ fn test_merge_components_responses_and_parameters ( ) {
507+ use crate :: route:: { Parameter , ParameterLocation , Response } ;
508+
509+ let response = |desc : & str | Response {
510+ description : desc. to_string ( ) ,
511+ headers : None ,
512+ content : None ,
513+ } ;
514+
515+ let mut base = create_base_openapi ( ) ;
516+ base. components = Some ( Components {
517+ schemas : None ,
518+ responses : Some ( BTreeMap :: from ( [ ( "NotFound" . to_string ( ) , response ( "base" ) ) ] ) ) ,
519+ parameters : None ,
520+ examples : None ,
521+ request_bodies : None ,
522+ headers : None ,
523+ security_schemes : None ,
524+ } ) ;
525+
526+ let mut other = create_base_openapi ( ) ;
527+ other. components = Some ( Components {
528+ schemas : None ,
529+ responses : Some ( BTreeMap :: from ( [
530+ ( "NotFound" . to_string ( ) , response ( "other-dup" ) ) ,
531+ ( "ServerError" . to_string ( ) , response ( "other" ) ) ,
532+ ] ) ) ,
533+ parameters : Some ( BTreeMap :: from ( [ (
534+ "PageParam" . to_string ( ) ,
535+ Parameter {
536+ name : "page" . to_string ( ) ,
537+ r#in : ParameterLocation :: Query ,
538+ description : None ,
539+ required : None ,
540+ schema : None ,
541+ example : None ,
542+ } ,
543+ ) ] ) ) ,
544+ examples : None ,
545+ request_bodies : None ,
546+ headers : None ,
547+ security_schemes : None ,
548+ } ) ;
549+
550+ base. merge ( other) ;
551+
552+ let comps = base. components . as_ref ( ) . unwrap ( ) ;
553+ let responses = comps. responses . as_ref ( ) . unwrap ( ) ;
554+ // other's non-conflicting response is merged in (previously dropped).
555+ assert ! ( responses. contains_key( "NotFound" ) ) ;
556+ assert ! ( responses. contains_key( "ServerError" ) ) ;
557+ // self wins on conflict.
558+ assert_eq ! ( responses. get( "NotFound" ) . unwrap( ) . description, "base" ) ;
559+ // parameters adopted from other (base had none) — previously dropped.
560+ assert ! ( comps. parameters. as_ref( ) . unwrap( ) . contains_key( "PageParam" ) ) ;
561+ }
562+
563+ #[ test]
564+ fn test_merge_top_level_servers_security_external_docs ( ) {
565+ use crate :: schema:: ExternalDocumentation ;
566+
567+ // base sets none of the three → adopts other's.
568+ let mut base = create_base_openapi ( ) ;
569+ let mut other = create_base_openapi ( ) ;
570+ other. servers = Some ( vec ! [ Server {
571+ url: "https://api.example.com" . to_string( ) ,
572+ description: None ,
573+ variables: None ,
574+ } ] ) ;
575+ other. security = Some ( vec ! [ BTreeMap :: from( [ (
576+ "bearerAuth" . to_string( ) ,
577+ Vec :: new( ) ,
578+ ) ] ) ] ) ;
579+ other. external_docs = Some ( ExternalDocumentation {
580+ description : None ,
581+ url : "https://docs.example.com" . to_string ( ) ,
582+ } ) ;
583+
584+ base. merge ( other) ;
585+
586+ assert_eq ! (
587+ base. servers. as_ref( ) . unwrap( ) [ 0 ] . url,
588+ "https://api.example.com"
589+ ) ;
590+ assert ! ( base. security. is_some( ) ) ;
591+ assert_eq ! (
592+ base. external_docs. as_ref( ) . unwrap( ) . url,
593+ "https://docs.example.com"
594+ ) ;
595+
596+ // self-wins: base already has servers → other's ignored.
597+ let mut base2 = create_base_openapi ( ) ;
598+ base2. servers = Some ( vec ! [ Server {
599+ url: "https://self.example.com" . to_string( ) ,
600+ description: None ,
601+ variables: None ,
602+ } ] ) ;
603+ let mut other2 = create_base_openapi ( ) ;
604+ other2. servers = Some ( vec ! [ Server {
605+ url: "https://other.example.com" . to_string( ) ,
606+ description: None ,
607+ variables: None ,
608+ } ] ) ;
609+ base2. merge ( other2) ;
610+ assert_eq ! (
611+ base2. servers. as_ref( ) . unwrap( ) [ 0 ] . url,
612+ "https://self.example.com"
613+ ) ;
614+ }
477615}
0 commit comments