@@ -206,6 +206,12 @@ func joinSingleDocument(ctx context.Context, result, doc *OpenAPI, docPath strin
206206 return fmt .Errorf ("failed to update references in document: %w" , err )
207207 }
208208
209+ // Collect existing operationIds to detect conflicts
210+ usedOperationIds := collectOperationIds (result )
211+
212+ // Resolve operationId conflicts in the source document
213+ resolveOperationIdConflicts (doc , usedOperationIds , docPath )
214+
209215 // Join paths with conflict resolution
210216 joinPaths (result , doc , docPath , usedPathNames )
211217
@@ -232,19 +238,94 @@ func joinPaths(result, src *OpenAPI, srcPath string, usedPathNames map[string]bo
232238 result .Paths = NewPaths ()
233239 }
234240
235- for path , pathItem := range src .Paths .All () {
241+ for path , srcPathItem := range src .Paths .All () {
236242 if ! usedPathNames [path ] {
237243 // No conflict, add directly
238- result .Paths .Set (path , pathItem )
244+ result .Paths .Set (path , srcPathItem )
239245 usedPathNames [path ] = true
240246 } else {
241- // Conflict detected, create new path with fragment
242- newPath := generateConflictPath (path , srcPath )
243- result .Paths .Set (newPath , pathItem )
244- usedPathNames [newPath ] = true
247+ // Path exists, need to check for operation conflicts
248+ existingPathItem , exists := result .Paths .Get (path )
249+ if ! exists || existingPathItem == nil || existingPathItem .Object == nil || srcPathItem == nil || srcPathItem .Object == nil {
250+ // Safety check - if we can't access the operations, create conflict path
251+ newPath := generateConflictPath (path , srcPath )
252+ result .Paths .Set (newPath , srcPathItem )
253+ usedPathNames [newPath ] = true
254+ continue
255+ }
256+
257+ // Try to merge operations from source into existing path
258+ conflictingOperations := mergePathItemOperations (existingPathItem .Object , srcPathItem .Object )
259+
260+ if len (conflictingOperations ) == 0 {
261+ // No conflicts, all operations merged successfully - existing path already updated
262+ continue
263+ } else {
264+ // Some operations had conflicts, create new path for conflicting operations only
265+ conflictPathItem := createPathItemWithOperations (conflictingOperations )
266+
267+ newPath := generateConflictPath (path , srcPath )
268+ result .Paths .Set (newPath , & ReferencedPathItem {Object : conflictPathItem })
269+ usedPathNames [newPath ] = true
270+ }
271+ }
272+ }
273+ }
274+
275+ // ConflictingOperation represents an operation that conflicts with an existing one
276+ type ConflictingOperation struct {
277+ Method HTTPMethod
278+ Operation * Operation
279+ }
280+
281+ // mergePathItemOperations attempts to merge operations from srcPathItem into existingPathItem
282+ // Returns conflicting operations that couldn't be merged
283+ func mergePathItemOperations (existingPathItem , srcPathItem * PathItem ) []ConflictingOperation {
284+ conflictingOperations := []ConflictingOperation {}
285+
286+ // Iterate through all operations in the source PathItem
287+ for method , srcOp := range srcPathItem .All () {
288+ if srcOp == nil {
289+ continue
290+ }
291+
292+ // Check if existing PathItem has this method
293+ existingOp := existingPathItem .GetOperation (method )
294+
295+ if existingOp == nil {
296+ // No conflict, add the operation to existing PathItem
297+ existingPathItem .Set (method , srcOp )
298+ } else {
299+ // Both have this method, check if operations are identical
300+ srcHash := hashing .Hash (srcOp )
301+ existingHash := hashing .Hash (existingOp )
302+
303+ if srcHash == existingHash {
304+ // Identical operations, keep existing (deduplicate)
305+ continue
306+ } else {
307+ // Different operations, this is a conflict
308+ conflictingOperations = append (conflictingOperations , ConflictingOperation {
309+ Method : method ,
310+ Operation : srcOp ,
311+ })
312+ }
245313 }
246314 }
247315
316+ return conflictingOperations
317+ }
318+
319+ // createPathItemWithOperations creates a new PathItem containing only the specified conflicting operations
320+ func createPathItemWithOperations (conflictingOps []ConflictingOperation ) * PathItem {
321+ pathItem := NewPathItem ()
322+
323+ // Add each conflicting operation with its original method
324+ for _ , conflictOp := range conflictingOps {
325+ pathItem .Set (conflictOp .Method , conflictOp .Operation )
326+ }
327+
328+ return pathItem
248329}
249330
250331// generateConflictPath creates a new path with a fragment containing the file name
@@ -738,6 +819,110 @@ func joinTags(result, src *OpenAPI) {
738819 }
739820}
740821
822+ // collectOperationIds collects all operationIds from the given OpenAPI document
823+ func collectOperationIds (doc * OpenAPI ) map [string ]bool {
824+ usedOperationIds := make (map [string ]bool )
825+
826+ if doc .Paths != nil {
827+ for _ , pathItem := range doc .Paths .All () {
828+ if pathItem == nil || pathItem .Object == nil {
829+ continue
830+ }
831+
832+ // Check all operations in the path item
833+ for _ , operation := range pathItem .Object .All () {
834+ if operation != nil && operation .OperationID != nil && * operation .OperationID != "" {
835+ usedOperationIds [* operation .OperationID ] = true
836+ }
837+ }
838+ }
839+ }
840+
841+ // Also check webhooks
842+ if doc .Webhooks != nil {
843+ for _ , webhook := range doc .Webhooks .All () {
844+ if webhook == nil || webhook .Object == nil {
845+ continue
846+ }
847+
848+ for _ , operation := range webhook .Object .All () {
849+ if operation != nil && operation .OperationID != nil && * operation .OperationID != "" {
850+ usedOperationIds [* operation .OperationID ] = true
851+ }
852+ }
853+ }
854+ }
855+
856+ return usedOperationIds
857+ }
858+
859+ // resolveOperationIdConflicts resolves operationId conflicts by adding #docname suffix
860+ func resolveOperationIdConflicts (doc * OpenAPI , usedOperationIds map [string ]bool , docPath string ) {
861+ // Extract document name from path for suffix
862+ docName := generateDocumentName (docPath )
863+
864+ if doc .Paths != nil {
865+ for _ , pathItem := range doc .Paths .All () {
866+ if pathItem == nil || pathItem .Object == nil {
867+ continue
868+ }
869+
870+ // Check all operations in the path item
871+ for _ , operation := range pathItem .Object .All () {
872+ if operation != nil && operation .OperationID != nil && * operation .OperationID != "" {
873+ originalId := * operation .OperationID
874+ if usedOperationIds [originalId ] {
875+ // Conflict detected, add suffix
876+ newId := originalId + "#" + docName
877+ operation .OperationID = & newId
878+ } else {
879+ // No conflict, mark as used
880+ usedOperationIds [originalId ] = true
881+ }
882+ }
883+ }
884+ }
885+ }
886+
887+ // Also handle webhooks
888+ if doc .Webhooks != nil {
889+ for _ , webhook := range doc .Webhooks .All () {
890+ if webhook == nil || webhook .Object == nil {
891+ continue
892+ }
893+
894+ for _ , operation := range webhook .Object .All () {
895+ if operation != nil && operation .OperationID != nil && * operation .OperationID != "" {
896+ originalId := * operation .OperationID
897+ if usedOperationIds [originalId ] {
898+ // Conflict detected, add suffix
899+ newId := originalId + "#" + docName
900+ operation .OperationID = & newId
901+ } else {
902+ // No conflict, mark as used
903+ usedOperationIds [originalId ] = true
904+ }
905+ }
906+ }
907+ }
908+ }
909+ }
910+
911+ // generateDocumentName extracts a clean document name from the file path
912+ func generateDocumentName (filePath string ) string {
913+ // Extract filename without extension
914+ baseName := filepath .Base (filePath )
915+ ext := filepath .Ext (baseName )
916+ if ext != "" {
917+ baseName = baseName [:len (baseName )- len (ext )]
918+ }
919+
920+ // Clean the filename to make it safe
921+ safeFileName := regexp .MustCompile (`[^a-zA-Z0-9_-]` ).ReplaceAllString (baseName , "_" )
922+
923+ return safeFileName
924+ }
925+
741926// joinServersAndSecurity handles smart conflict resolution for servers and security
742927func joinServersAndSecurity (result , src * OpenAPI ) {
743928 // Check if servers are identical (by hash)
0 commit comments