Repository: OpenLiberty/open-liberty (2025-07-21T09:26:12Z)

Issue:
MP OpenAPI does not preserve the order of maps when merging documents
**Describe the bug**  
When we merge two openAPI documents we do not preserve the ordering of elements of a map, as this ordering defines the order elements are displayed to the user this is undesired.

**Steps to Reproduce**  
Run the new fat test in the PR attached to this issue

**Expected behavior**  
The map ordering is preserved




Discussion:

Patch:
diff --git a/dev/io.openliberty.microprofile.openapi.2.0.internal/src/io/openliberty/microprofile/openapi20/internal/merge/MergeProcessorImpl.java b/dev/io.openliberty.microprofile.openapi.2.0.internal/src/io/openliberty/microprofile/openapi20/internal/merge/MergeProcessorImpl.java
index 3a74cb905f42..46fe692eac84 100644
--- a/dev/io.openliberty.microprofile.openapi.2.0.internal/src/io/openliberty/microprofile/openapi20/internal/merge/MergeProcessorImpl.java
+++ b/dev/io.openliberty.microprofile.openapi.2.0.internal/src/io/openliberty/microprofile/openapi20/internal/merge/MergeProcessorImpl.java
@@ -1,5 +1,5 @@
 /*******************************************************************************
- * Copyright (c) 2021, 2024 IBM Corporation and others.
+ * Copyright (c) 2021, 2025 IBM Corporation and others.
  * All rights reserved. This program and the accompanying materials
  * are made available under the terms of the Eclipse Public License 2.0
  * which accompanies this distribution, and is available at
@@ -20,6 +20,7 @@
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Map.Entry;
@@ -386,7 +387,7 @@ protected <T> Map<String, T> renameComponents(NameType nameType, Map<String, T>
                 return null;
             }
 
-            HashMap<String, T> newMap = new HashMap<>();
+            Map<String, T> newMap = new LinkedHashMap<>(); //We must preserve the order of components as the order is human visible.
             for (Entry<String, T> entry : componentMap.entrySet()) {
                 String key = entry.getKey();
                 newMap.put(documentNameProcessor.createUniqueName(nameType, key, entry.getValue()), entry.getValue());
@@ -499,7 +500,7 @@ private static void prependPaths(OpenAPI model, String contextRoot, DocumentName
                 removeContextRoot(server, contextRoot);
             }
 
-            Map<String, PathItem> newPathItems = new HashMap<>();
+            Map<String, PathItem> newPathItems = new LinkedHashMap<>(); //We must preserve the order of path items as the order is human visible.
             for (Entry<String, PathItem> entry : pathItems.entrySet()) {
                 PathItem pathItem = entry.getValue();
 
diff --git a/dev/io.openliberty.microprofile.openapi.2.0.internal/src/io/openliberty/microprofile/openapi20/internal/merge/ModelCopy.java b/dev/io.openliberty.microprofile.openapi.2.0.internal/src/io/openliberty/microprofile/openapi20/internal/merge/ModelCopy.java
index adf418578962..cfd1398cc66b 100644
--- a/dev/io.openliberty.microprofile.openapi.2.0.internal/src/io/openliberty/microprofile/openapi20/internal/merge/ModelCopy.java
+++ b/dev/io.openliberty.microprofile.openapi.2.0.internal/src/io/openliberty/microprofile/openapi20/internal/merge/ModelCopy.java
@@ -1,10 +1,10 @@
 /*******************************************************************************
- * Copyright (c) 2021 IBM Corporation and others.
+ * Copyright (c) 2021, 2025 IBM Corporation and others.
  * All rights reserved. This program and the accompanying materials
  * are made available under the terms of the Eclipse Public License 2.0
  * which accompanies this distribution, and is available at
  * http://www.eclipse.org/legal/epl-2.0/
- * 
+ *
  * SPDX-License-Identifier: EPL-2.0
  *
  * Contributors:
@@ -13,7 +13,7 @@
 package io.openliberty.microprofile.openapi20.internal.merge;
 
 import java.util.ArrayList;
-import java.util.HashMap;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Map.Entry;
@@ -66,7 +66,7 @@ private static Object copyList(List<?> copyFrom) {
 
     @Trivial
     private static Map<Object, Object> copyMap(Map<?, ?> copyFrom) {
-        Map<Object, Object> copyTo = new HashMap<>();
+        Map<Object, Object> copyTo = new LinkedHashMap<>();
         for (Entry<?, ?> entry : copyFrom.entrySet()) {
             copyTo.put(entry.getKey(), doCopy(entry.getValue()));
         }
diff --git a/dev/io.openliberty.microprofile.openapi.2.0.internal_fat/fat/src/io/openliberty/microprofile/openapi20/fat/deployments/MergeConfigServerXMLTest.java b/dev/io.openliberty.microprofile.openapi.2.0.internal_fat/fat/src/io/openliberty/microprofile/openapi20/fat/deployments/MergeConfigServerXMLTest.java
index 862c63884633..0c9ae58aad2d 100644
--- a/dev/io.openliberty.microprofile.openapi.2.0.internal_fat/fat/src/io/openliberty/microprofile/openapi20/fat/deployments/MergeConfigServerXMLTest.java
+++ b/dev/io.openliberty.microprofile.openapi.2.0.internal_fat/fat/src/io/openliberty/microprofile/openapi20/fat/deployments/MergeConfigServerXMLTest.java
@@ -12,6 +12,7 @@
 import static com.ibm.websphere.simplicity.ShrinkHelper.DeployOptions.DISABLE_VALIDATION;
 import static com.ibm.websphere.simplicity.ShrinkHelper.DeployOptions.SERVER_ONLY;
 import static io.openliberty.microprofile.openapi20.fat.utils.OpenAPITestUtil.assertEqualIgnoringPropertyOrder;
+import static org.hamcrest.Matchers.contains;
 import static org.hamcrest.Matchers.hasSize;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertThat;
@@ -36,6 +37,7 @@
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.node.JsonNodeFactory;
 import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.ibm.websphere.simplicity.PropertiesAsset;
 import com.ibm.websphere.simplicity.ShrinkHelper;
 import com.ibm.websphere.simplicity.config.MpOpenAPIElement;
 import com.ibm.websphere.simplicity.config.MpOpenAPIInfoElement;
@@ -659,6 +661,38 @@ public void testInvalidServerXMLJustSlash() throws Exception {
 
     }
 
+    //This test creates an openAPI doc with a map containing values from two apps, and checks they preserve their ordering
+    @Test
+
+    public void testMergePreservesMapOrdering() throws Exception {
+        setMergeConfig(list("test2", "test3"), null, null);
+
+        PropertiesAsset scanConfig = new PropertiesAsset().addProperty("mp.openapi.scan.disable",
+                                                                       "true");
+
+        WebArchive war2 = ShrinkWrap.create(WebArchive.class, "test2.war")
+                                    .addClasses(DeploymentTestApp.class, DeploymentTestResource.class)
+                                    .addAsResource(scanConfig, "META-INF/microprofile-config.properties")
+                                    .addAsManifestResource(DeploymentTestResource.class.getPackage(), "static-file-foo.json", "openapi.json");
+        deployApp(war2);
+
+        WebArchive war3 = ShrinkWrap.create(WebArchive.class, "test3.war")
+                                    .addClasses(DeploymentTestApp.class, DeploymentTestResource.class)
+                                    .addAsResource(scanConfig, "META-INF/microprofile-config.properties")
+                                    .addAsManifestResource(DeploymentTestResource.class.getPackage(), "static-file-bar.json", "openapi.json");
+        deployApp(war3);
+
+        // check that documentation includes all paths in the right order
+        String doc = OpenAPIConnection.openAPIDocsConnection(server, false).download();
+        JsonNode openapiNode = OpenAPITestUtil.readYamlTree(doc).get("paths");
+
+        List<String> pathNames = new ArrayList<>();
+        openapiNode.fieldNames().forEachRemaining(pathNames::add);
+
+        assertThat("Path names not found in expected order in " + doc,
+                   pathNames, contains("/test2/foo1", "/test2/foo2", "/test2/foo3", "/test3/bar1", "/test3/bar2", "/test3/bar3"));
+    }
+
     private void setMergeConfig(List<String> included, List<String> excluded, MpOpenAPIInfoElement info) throws Exception {
         ServerConfiguration config = server.getServerConfiguration();
         MpOpenAPIElement mpOpenAPI = config.getMpOpenAPIElement();


Test patch:
diff --git a/dev/io.openliberty.microprofile.openapi.2.0.internal_fat/fat/src/io/openliberty/microprofile/openapi20/fat/deployments/test1/static-file-bar.json b/dev/io.openliberty.microprofile.openapi.2.0.internal_fat/fat/src/io/openliberty/microprofile/openapi20/fat/deployments/test1/static-file-bar.json
new file mode 100644
index 000000000000..0cef39ac0a75
--- /dev/null
+++ b/dev/io.openliberty.microprofile.openapi.2.0.internal_fat/fat/src/io/openliberty/microprofile/openapi20/fat/deployments/test1/static-file-bar.json
@@ -0,0 +1,64 @@
+{
+	"openapi": "3.0.0",
+	"info": {
+		"title": "Test REST API",
+		"version": "1.0.0",
+		"description": "Test REST API in Liberty with tabs in json",
+		"termsOfService": "http://example.com/terms/",
+		"contact": {
+			"name": "Open API support team",
+			"email": "oapisupport@example.com",
+			"url": "http://www.example.com/support"
+		}
+	},
+	"paths": {
+		"/bar1": {
+			"get": {
+				"summary": "openapi.json - Get test data",
+				"responses": {
+					"200": {
+						"description": "Test data retrieved",
+						"schema": {
+							"$ref": "#/components/schemas/TestData"
+						}
+					},
+					"404": {
+						"description": "Test data not found"
+					}
+				}
+			}
+		},
+		"/bar2": {
+			"get": {
+				"summary": "openapi.json - Get test data",
+				"responses": {
+					"200": {
+						"description": "Test data retrieved",
+						"schema": {
+							"$ref": "#/components/schemas/TestData"
+						}
+					},
+					"404": {
+						"description": "Test data not found"
+					}
+				}
+			}
+		},
+		"/bar3": {
+			"get": {
+				"summary": "openapi.json - Get test data",
+				"responses": {
+					"200": {
+						"description": "Test data retrieved",
+						"schema": {
+							"$ref": "#/components/schemas/TestData"
+						}
+					},
+					"404": {
+						"description": "Test data not found"
+					}
+				}
+			}
+		}
+	}
+}
\ No newline at end of file
diff --git a/dev/io.openliberty.microprofile.openapi.2.0.internal_fat/fat/src/io/openliberty/microprofile/openapi20/fat/deployments/test1/static-file-foo.json b/dev/io.openliberty.microprofile.openapi.2.0.internal_fat/fat/src/io/openliberty/microprofile/openapi20/fat/deployments/test1/static-file-foo.json
new file mode 100644
index 000000000000..940910c68423
--- /dev/null
+++ b/dev/io.openliberty.microprofile.openapi.2.0.internal_fat/fat/src/io/openliberty/microprofile/openapi20/fat/deployments/test1/static-file-foo.json
@@ -0,0 +1,64 @@
+{
+	"openapi": "3.0.0",
+	"info": {
+		"title": "Test REST API",
+		"version": "1.0.0",
+		"description": "Test REST API in Liberty with tabs in json",
+		"termsOfService": "http://example.com/terms/",
+		"contact": {
+			"name": "Open API support team",
+			"email": "oapisupport@example.com",
+			"url": "http://www.example.com/support"
+		}
+	},
+	"paths": {
+		"/foo1": {
+			"get": {
+				"summary": "openapi.json - Get test data",
+				"responses": {
+					"200": {
+						"description": "Test data retrieved",
+						"schema": {
+							"$ref": "#/components/schemas/TestData"
+						}
+					},
+					"404": {
+						"description": "Test data not found"
+					}
+				}
+			}
+		},
+		"/foo2": {
+			"get": {
+				"summary": "openapi.json - Get test data",
+				"responses": {
+					"200": {
+						"description": "Test data retrieved",
+						"schema": {
+							"$ref": "#/components/schemas/TestData"
+						}
+					},
+					"404": {
+						"description": "Test data not found"
+					}
+				}
+			}
+		},
+		"/foo3": {
+			"get": {
+				"summary": "openapi.json - Get test data",
+				"responses": {
+					"200": {
+						"description": "Test data retrieved",
+						"schema": {
+							"$ref": "#/components/schemas/TestData"
+						}
+					},
+					"404": {
+						"description": "Test data not found"
+					}
+				}
+			}
+		}
+	}
+}
\ No newline at end of file