@@ -168,6 +168,63 @@ fn has_any_component_map(components: &Components) -> bool {
168168 || components. security_schemes . is_some ( )
169169}
170170
171+ /// Merge `other`'s per-method operations into `into` with **self-wins**
172+ /// semantics: an operation (or path-level field) already present on `into`
173+ /// is kept; a slot empty on `into` is filled from `other`.
174+ ///
175+ /// Applied on a path-key conflict so two apps that define the same path
176+ /// under different methods both keep their operations, instead of the
177+ /// incoming [`PathItem`] being dropped whole. Destructuring `other` keeps
178+ /// this exhaustive — adding a `PathItem` field forces this to be updated.
179+ fn merge_path_item ( into : & mut PathItem , other : PathItem ) {
180+ let PathItem {
181+ get,
182+ post,
183+ put,
184+ patch,
185+ delete,
186+ head,
187+ options,
188+ trace,
189+ parameters,
190+ summary,
191+ description,
192+ } = other;
193+ if into. get . is_none ( ) {
194+ into. get = get;
195+ }
196+ if into. post . is_none ( ) {
197+ into. post = post;
198+ }
199+ if into. put . is_none ( ) {
200+ into. put = put;
201+ }
202+ if into. patch . is_none ( ) {
203+ into. patch = patch;
204+ }
205+ if into. delete . is_none ( ) {
206+ into. delete = delete;
207+ }
208+ if into. head . is_none ( ) {
209+ into. head = head;
210+ }
211+ if into. options . is_none ( ) {
212+ into. options = options;
213+ }
214+ if into. trace . is_none ( ) {
215+ into. trace = trace;
216+ }
217+ if into. parameters . is_none ( ) {
218+ into. parameters = parameters;
219+ }
220+ if into. summary . is_none ( ) {
221+ into. summary = summary;
222+ }
223+ if into. description . is_none ( ) {
224+ into. description = description;
225+ }
226+ }
227+
171228impl OpenApi {
172229 /// Merge another `OpenAPI` document into this one.
173230 ///
@@ -177,9 +234,21 @@ impl OpenApi {
177234 /// and `external_docs` are adopted from `other` only when `self` has
178235 /// not set its own. On any key/field conflict, `self` takes precedence.
179236 pub fn merge ( & mut self , other : Self ) {
180- // Merge paths (self takes precedence on conflict)
237+ // Merge paths. On a path-key conflict, merge per HTTP method
238+ // (self-wins per operation) instead of dropping the incoming
239+ // `PathItem` wholesale: two merged apps that both define the same
240+ // path under DIFFERENT methods (parent `GET /users`, child
241+ // `POST /users`) must keep BOTH operations in the generated
242+ // document — otherwise the spec under-documents what the merged
243+ // router actually serves at runtime.
181244 for ( path, item) in other. paths {
182- self . paths . entry ( path) . or_insert ( item) ;
245+ use std:: collections:: btree_map:: Entry ;
246+ match self . paths . entry ( path) {
247+ Entry :: Vacant ( slot) => {
248+ slot. insert ( item) ;
249+ }
250+ Entry :: Occupied ( mut slot) => merge_path_item ( slot. get_mut ( ) , item) ,
251+ }
183252 }
184253
185254 // Merge components (every reusable component kind, self-wins on
@@ -314,6 +383,81 @@ mod tests {
314383 ) ;
315384 }
316385
386+ fn create_post_path_item ( summary : & str ) -> PathItem {
387+ PathItem {
388+ post : Some ( Operation {
389+ summary : Some ( summary. to_string ( ) ) ,
390+ description : None ,
391+ operation_id : None ,
392+ tags : None ,
393+ parameters : None ,
394+ request_body : None ,
395+ responses : BTreeMap :: new ( ) ,
396+ security : None ,
397+ deprecated : None ,
398+ } ) ,
399+ ..Default :: default ( )
400+ }
401+ }
402+
403+ #[ test]
404+ fn test_merge_same_path_different_methods_are_combined ( ) {
405+ // Regression: a path-key conflict must merge per HTTP method, not
406+ // drop the incoming PathItem wholesale. Parent defines GET /users,
407+ // child defines POST /users — the merged document must expose BOTH
408+ // operations (otherwise the spec under-documents the merged router).
409+ let mut base = create_base_openapi ( ) ;
410+ base. paths
411+ . insert ( "/users" . to_string ( ) , create_path_item ( "List users" ) ) ; // GET
412+
413+ let mut other = create_base_openapi ( ) ;
414+ other
415+ . paths
416+ . insert ( "/users" . to_string ( ) , create_post_path_item ( "Create user" ) ) ; // POST
417+
418+ base. merge ( other) ;
419+
420+ let users = base. paths . get ( "/users" ) . expect ( "/users present" ) ;
421+ // self-wins GET is preserved
422+ assert_eq ! (
423+ users. get. as_ref( ) . unwrap( ) . summary,
424+ Some ( "List users" . to_string( ) )
425+ ) ;
426+ // incoming POST is merged in (previously dropped on the whole-item
427+ // `or_insert`)
428+ assert_eq ! (
429+ users. post. as_ref( ) . unwrap( ) . summary,
430+ Some ( "Create user" . to_string( ) )
431+ ) ;
432+ }
433+
434+ #[ test]
435+ fn test_merge_same_path_same_method_self_wins ( ) {
436+ // Same path AND same method on both sides: self's operation is kept,
437+ // the incoming one is discarded.
438+ let mut base = create_base_openapi ( ) ;
439+ base. paths
440+ . insert ( "/users" . to_string ( ) , create_path_item ( "Base get" ) ) ;
441+
442+ let mut other = create_base_openapi ( ) ;
443+ other
444+ . paths
445+ . insert ( "/users" . to_string ( ) , create_path_item ( "Other get" ) ) ;
446+
447+ base. merge ( other) ;
448+
449+ assert_eq ! (
450+ base. paths
451+ . get( "/users" )
452+ . unwrap( )
453+ . get
454+ . as_ref( )
455+ . unwrap( )
456+ . summary,
457+ Some ( "Base get" . to_string( ) )
458+ ) ;
459+ }
460+
317461 #[ test]
318462 fn test_merge_schemas ( ) {
319463 let mut base = create_base_openapi ( ) ;
0 commit comments