diff --git a/springdoc-openapi-webflux-core/src/main/java/org/springdoc/webflux/api/OpenApiResource.java b/springdoc-openapi-webflux-core/src/main/java/org/springdoc/webflux/api/OpenApiResource.java index b4424b985..d163846a2 100644 --- a/springdoc-openapi-webflux-core/src/main/java/org/springdoc/webflux/api/OpenApiResource.java +++ b/springdoc-openapi-webflux-core/src/main/java/org/springdoc/webflux/api/OpenApiResource.java @@ -20,13 +20,7 @@ package org.springdoc.webflux.api; -import java.util.HashSet; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.Set; +import java.util.*; import com.fasterxml.jackson.core.JsonProcessingException; import io.swagger.v3.core.util.Json; @@ -190,7 +184,9 @@ protected void getPaths(Map restControllers) { * @param map the map */ protected void calculatePath(Map restControllers, Map map) { - for (Map.Entry entry : map.entrySet()) { + List> entries = new ArrayList<>(map.entrySet()); + entries.sort(byReversedRequestMappingInfos()); + for (Map.Entry entry : entries) { RequestMappingInfo requestMappingInfo = entry.getKey(); HandlerMethod handlerMethod = entry.getValue(); PatternsRequestCondition patternsRequestCondition = requestMappingInfo.getPatternsCondition(); @@ -215,6 +211,12 @@ && isFilterCondition(handlerMethod, operationPath, produces, consumes, headers)) } } + private Comparator> byReversedRequestMappingInfos() { + return Comparator., String> + comparing(a -> a.getKey().toString()) + .reversed(); + } + /** * Gets web flux router function paths. */ diff --git a/springdoc-openapi-webflux-core/src/test/java/test/org/springdoc/api/app81/OperationIdController.java b/springdoc-openapi-webflux-core/src/test/java/test/org/springdoc/api/app81/OperationIdController.java new file mode 100644 index 000000000..90031bce0 --- /dev/null +++ b/springdoc-openapi-webflux-core/src/test/java/test/org/springdoc/api/app81/OperationIdController.java @@ -0,0 +1,49 @@ +/* + * + * * Copyright 2019-2020 the original author or authors. + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * https://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package test.org.springdoc.api.app81; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Mono; + +@RestController +public class OperationIdController { + + @GetMapping(path = "/test_0") // gets operationId opIdTest_3 + public Mono opIdTest() { + return null; + } + + @GetMapping(path = "/test_1") // gets operationId opIdTest_2 + public Mono opIdTest(@RequestParam String param) { + return null; + } + + @GetMapping(path = "/test_2") // gets operationId opIdTest_1 + public Mono opIdTest(@RequestParam Integer param) { + return null; + } + + @GetMapping(path = "/test_3") // gets operationId opIdTest + public Mono opIdTest(@RequestParam Boolean param) { + return null; + } + +} diff --git a/springdoc-openapi-webflux-core/src/test/java/test/org/springdoc/api/app81/SpringDocApp81Test.java b/springdoc-openapi-webflux-core/src/test/java/test/org/springdoc/api/app81/SpringDocApp81Test.java new file mode 100644 index 000000000..46dd62389 --- /dev/null +++ b/springdoc-openapi-webflux-core/src/test/java/test/org/springdoc/api/app81/SpringDocApp81Test.java @@ -0,0 +1,93 @@ +/* + * + * * + * * * + * * * * Copyright 2019-2020 the original author or authors. + * * * * + * * * * Licensed under the Apache License, Version 2.0 (the "License"); + * * * * you may not use this file except in compliance with the License. + * * * * You may obtain a copy of the License at + * * * * + * * * * https://www.apache.org/licenses/LICENSE-2.0 + * * * * + * * * * Unless required by applicable law or agreed to in writing, software + * * * * distributed under the License is distributed on an "AS IS" BASIS, + * * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * * * See the License for the specific language governing permissions and + * * * * limitations under the License. + * * * + * * + * + * + */ +package test.org.springdoc.api.app81; + +import org.junit.jupiter.api.RepeatedTest; +import org.springdoc.webflux.api.OpenApiResource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient; +import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.reactive.result.method.RequestMappingInfo; +import org.springframework.web.reactive.result.method.RequestMappingInfoHandlerMapping; + +import java.net.URI; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ThreadLocalRandom; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.skyscreamer.jsonassert.JSONAssert.assertEquals; +import static org.springdoc.core.Constants.SPRINGDOC_CACHE_DISABLED; +import static test.org.springdoc.api.AbstractSpringDocTest.getContent; + + +/** + * Tests deterministic creation of operationIds + */ +@AutoConfigureWebTestClient(timeout = "3600000") +@WebFluxTest(properties = SPRINGDOC_CACHE_DISABLED + "=true") +@ActiveProfiles("test") +public class SpringDocApp81Test { + + @SpringBootApplication + @ComponentScan(basePackages = {"org.springdoc", "test.org.springdoc.api.app81"}) + static class SpringDocTestApp { + } + + @Autowired + OpenApiResource resource; + + @Autowired + RequestMappingInfoHandlerMapping mappingInfoHandlerMapping; + + @RepeatedTest(10) + public void shouldGenerateOperationIdsDeterministically() throws Exception { + shuffleSpringHandlerMethods(); + + ServerHttpRequest request = mock(ServerHttpRequest.class); + when(request.getURI()).thenReturn(URI.create("http://localhost")); + + String expected = getContent("results/app81.json"); + String openApi = resource.openapiJson(request, "").block(); + assertEquals(expected, openApi, true); + } + + private void shuffleSpringHandlerMethods() { + Map handlerMethods = mappingInfoHandlerMapping.getHandlerMethods(); + List> collect = new ArrayList<>(handlerMethods.entrySet()); + collect.sort(Comparator.comparing(a -> ThreadLocalRandom.current().nextBoolean() ? -1 : 1)); + + collect.forEach(e -> mappingInfoHandlerMapping.unregisterMapping(e.getKey())); + collect.forEach(e -> mappingInfoHandlerMapping.registerMapping(e.getKey(), e.getValue().getBean(), e.getValue().getMethod())); + } + +} diff --git a/springdoc-openapi-webflux-core/src/test/resources/results/app81.json b/springdoc-openapi-webflux-core/src/test/resources/results/app81.json new file mode 100644 index 000000000..bbbc64049 --- /dev/null +++ b/springdoc-openapi-webflux-core/src/test/resources/results/app81.json @@ -0,0 +1,127 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "OpenAPI definition", + "version": "v0" + }, + "servers": [ + { + "url": "http://localhost", + "description": "Generated server url" + } + ], + "paths": { + "/test_3": { + "get": { + "tags": [ + "operation-id-controller" + ], + "operationId": "opIdTest", + "parameters": [ + { + "name": "param", + "in": "query", + "required": true, + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/test_2": { + "get": { + "tags": [ + "operation-id-controller" + ], + "operationId": "opIdTest_1", + "parameters": [ + { + "name": "param", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/test_1": { + "get": { + "tags": [ + "operation-id-controller" + ], + "operationId": "opIdTest_2", + "parameters": [ + { + "name": "param", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/test_0": { + "get": { + "tags": [ + "operation-id-controller" + ], + "operationId": "opIdTest_3", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "type": "string" + } + } + } + } + } + } + } + }, + "components": {} +} \ No newline at end of file diff --git a/springdoc-openapi-webmvc-core/src/main/java/org/springdoc/webmvc/api/OpenApiResource.java b/springdoc-openapi-webmvc-core/src/main/java/org/springdoc/webmvc/api/OpenApiResource.java index be0b8c21d..2cdf6cec6 100644 --- a/springdoc-openapi-webmvc-core/src/main/java/org/springdoc/webmvc/api/OpenApiResource.java +++ b/springdoc-openapi-webmvc-core/src/main/java/org/springdoc/webmvc/api/OpenApiResource.java @@ -20,12 +20,7 @@ package org.springdoc.webmvc.api; -import java.util.HashSet; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; +import java.util.*; import javax.servlet.http.HttpServletRequest; @@ -241,7 +236,9 @@ protected void getPaths(Map restControllers) { * @param map the map */ protected void calculatePath(Map restControllers, Map map) { - for (Map.Entry entry : map.entrySet()) { + List> entries = new ArrayList<>(map.entrySet()); + entries.sort(byReversedRequestMappingInfos()); + for (Map.Entry entry : entries) { RequestMappingInfo requestMappingInfo = entry.getKey(); HandlerMethod handlerMethod = entry.getValue(); PatternsRequestCondition patternsRequestCondition = requestMappingInfo.getPatternsCondition(); @@ -265,6 +262,12 @@ && isFilterCondition(handlerMethod, operationPath, produces, consumes, headers)) } } + private Comparator> byReversedRequestMappingInfos() { + return Comparator., String> + comparing(a -> a.getKey().toString()) + .reversed(); + } + /** * Is rest controller boolean. * diff --git a/springdoc-openapi-webmvc-core/src/test/java/test/org/springdoc/api/app136/OperationIdController.java b/springdoc-openapi-webmvc-core/src/test/java/test/org/springdoc/api/app136/OperationIdController.java new file mode 100644 index 000000000..76f3d2ade --- /dev/null +++ b/springdoc-openapi-webmvc-core/src/test/java/test/org/springdoc/api/app136/OperationIdController.java @@ -0,0 +1,48 @@ +/* + * + * * Copyright 2019-2020 the original author or authors. + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * https://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package test.org.springdoc.api.app136; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class OperationIdController { + + @GetMapping(path = "/test_0") // gets operationId opIdTest_3 + public String opIdTest() { + return ""; + } + + @GetMapping(path = "/test_1") // gets operationId opIdTest_2 + public String opIdTest(@RequestParam String param) { + return ""; + } + + @GetMapping(path = "/test_2") // gets operationId opIdTest_1 + public String opIdTest(@RequestParam Integer param) { + return ""; + } + + @GetMapping(path = "/test_3") // gets operationId opIdTest + public String opIdTest(@RequestParam Boolean param) { + return ""; + } + +} diff --git a/springdoc-openapi-webmvc-core/src/test/java/test/org/springdoc/api/app136/SpringDocApp136Test.java b/springdoc-openapi-webmvc-core/src/test/java/test/org/springdoc/api/app136/SpringDocApp136Test.java new file mode 100644 index 000000000..885c65039 --- /dev/null +++ b/springdoc-openapi-webmvc-core/src/test/java/test/org/springdoc/api/app136/SpringDocApp136Test.java @@ -0,0 +1,90 @@ +/* + * + * * + * * * + * * * * Copyright 2019-2020 the original author or authors. + * * * * + * * * * Licensed under the Apache License, Version 2.0 (the "License"); + * * * * you may not use this file except in compliance with the License. + * * * * You may obtain a copy of the License at + * * * * + * * * * https://www.apache.org/licenses/LICENSE-2.0 + * * * * + * * * * Unless required by applicable law or agreed to in writing, software + * * * * distributed under the License is distributed on an "AS IS" BASIS, + * * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * * * See the License for the specific language governing permissions and + * * * * limitations under the License. + * * * + * * + * + * + */ +package test.org.springdoc.api.app136; + +import org.junit.jupiter.api.RepeatedTest; +import org.springdoc.webmvc.api.OpenApiResource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.mvc.method.RequestMappingInfo; +import org.springframework.web.servlet.mvc.method.RequestMappingInfoHandlerMapping; + +import javax.servlet.http.HttpServletRequest; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ThreadLocalRandom; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.skyscreamer.jsonassert.JSONAssert.assertEquals; +import static org.springdoc.core.Constants.SPRINGDOC_CACHE_DISABLED; +import static test.org.springdoc.api.AbstractSpringDocTest.getContent; + + +/** + * Tests deterministic creation of operationIds + */ +@ActiveProfiles("test") +@SpringBootTest(properties = {SPRINGDOC_CACHE_DISABLED + "=true"}) +@AutoConfigureMockMvc +public class SpringDocApp136Test { + + @Autowired + OpenApiResource resource; + + @Autowired + RequestMappingInfoHandlerMapping mappingInfoHandlerMapping; + + @SpringBootApplication + static class SpringDocTestApp { + } + + @RepeatedTest(10) + public void shouldGenerateOperationIdsDeterministically() throws Exception { + shuffleSpringHandlerMethods(); + + HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getRequestURL()).thenReturn(new StringBuffer("http://localhost")); + + String expected = getContent("results/app136.json"); + String openApi = resource.openapiJson(request, ""); + assertEquals(expected, openApi, true); + } + + private void shuffleSpringHandlerMethods() { + Map handlerMethods = mappingInfoHandlerMapping.getHandlerMethods(); + List> collect = new ArrayList<>(handlerMethods.entrySet()); + collect.sort(Comparator.comparing(a -> ThreadLocalRandom.current().nextBoolean() ? -1 : 1)); + + collect.forEach(e -> mappingInfoHandlerMapping.unregisterMapping(e.getKey())); + collect.forEach(e -> mappingInfoHandlerMapping.registerMapping(e.getKey(), e.getValue().getBean(), e.getValue().getMethod())); + } + +} diff --git a/springdoc-openapi-webmvc-core/src/test/resources/results/app136.json b/springdoc-openapi-webmvc-core/src/test/resources/results/app136.json new file mode 100644 index 000000000..bbbc64049 --- /dev/null +++ b/springdoc-openapi-webmvc-core/src/test/resources/results/app136.json @@ -0,0 +1,127 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "OpenAPI definition", + "version": "v0" + }, + "servers": [ + { + "url": "http://localhost", + "description": "Generated server url" + } + ], + "paths": { + "/test_3": { + "get": { + "tags": [ + "operation-id-controller" + ], + "operationId": "opIdTest", + "parameters": [ + { + "name": "param", + "in": "query", + "required": true, + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/test_2": { + "get": { + "tags": [ + "operation-id-controller" + ], + "operationId": "opIdTest_1", + "parameters": [ + { + "name": "param", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/test_1": { + "get": { + "tags": [ + "operation-id-controller" + ], + "operationId": "opIdTest_2", + "parameters": [ + { + "name": "param", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/test_0": { + "get": { + "tags": [ + "operation-id-controller" + ], + "operationId": "opIdTest_3", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "type": "string" + } + } + } + } + } + } + } + }, + "components": {} +} \ No newline at end of file diff --git a/springdoc-openapi-webmvc-core/src/test/resources/results/app2.json b/springdoc-openapi-webmvc-core/src/test/resources/results/app2.json index 19afef962..b2698996c 100644 --- a/springdoc-openapi-webmvc-core/src/test/resources/results/app2.json +++ b/springdoc-openapi-webmvc-core/src/test/resources/results/app2.json @@ -21,13 +21,13 @@ "name": "user", "description": "the user API" }, - { - "name": "store", - "description": "the store API" - }, { "name": "pet", "description": "the pet API" + }, + { + "name": "store", + "description": "the store API" } ], "paths": { diff --git a/springdoc-openapi-webmvc-core/src/test/resources/results/app45.json b/springdoc-openapi-webmvc-core/src/test/resources/results/app45.json index 33d1719f8..4cb50710d 100644 --- a/springdoc-openapi-webmvc-core/src/test/resources/results/app45.json +++ b/springdoc-openapi-webmvc-core/src/test/resources/results/app45.json @@ -28,7 +28,7 @@ "People" ], "description": "List all persons", - "operationId": "list", + "operationId": "list_1", "responses": { "200": { "description": "OK", @@ -57,7 +57,7 @@ "People" ], "description": "List all persons", - "operationId": "listTwo", + "operationId": "listTwo_1", "responses": { "200": { "description": "OK", @@ -81,7 +81,7 @@ "People" ], "description": "List all persons", - "operationId": "list_1", + "operationId": "list", "responses": { "200": { "description": "OK", @@ -110,7 +110,7 @@ "People" ], "description": "List all persons", - "operationId": "listTwo_1", + "operationId": "listTwo", "responses": { "200": { "description": "OK",