diff --git a/modules/virtualan-plugin/src/main/java/io/virtualan/core/VirtualServiceUtil.java b/modules/virtualan-plugin/src/main/java/io/virtualan/core/VirtualServiceUtil.java index b78df564..d420a63e 100644 --- a/modules/virtualan-plugin/src/main/java/io/virtualan/core/VirtualServiceUtil.java +++ b/modules/virtualan-plugin/src/main/java/io/virtualan/core/VirtualServiceUtil.java @@ -520,10 +520,12 @@ public Object returnResponse(Method method, MockServiceRequest mockServiceReques new ArrayList<>(returnMockResponseMap.values()); Collections.sort(returnMockResponseList, new BestMatchComparator()); VirtualServiceUtil.log.debug("Sorted list : " + returnMockResponseList); + // serve the best exact match rather than the head of the list, which can be a + // partial match (e.g. an exact match on a request with no params has no param count) rMockResponse = returnMockResponseList.stream() - .filter(ReturnMockResponse::isExactMatch).findAny().orElse(null); + .filter(ReturnMockResponse::isExactMatch).findFirst().orElse(null); if (rMockResponse != null) { - return getResponse(method, returnMockResponseList); + return getResponse(method, rMockResponse); } } else { VirtualServiceUtil.log.error( @@ -534,9 +536,12 @@ public Object returnResponse(Method method, MockServiceRequest mockServiceReques public Object getResponse(Method method, List returnMockResponseList) throws JAXBException { - ReturnMockResponse rMockResponse; + return getResponse(method, returnMockResponseList.iterator().next()); + } + + public Object getResponse(Method method, ReturnMockResponse rMockResponse) + throws JAXBException { ResponseEntity responseEntity; - rMockResponse = returnMockResponseList.iterator().next(); if (rMockResponse.getHeaderResponse() != null) { responseEntity = buildResponseEntity(rMockResponse.getMockResponse(), rMockResponse.getHeaderResponse()); diff --git a/modules/virtualan-plugin/src/main/java/io/virtualan/core/util/BestMatchComparator.java b/modules/virtualan-plugin/src/main/java/io/virtualan/core/util/BestMatchComparator.java index 6f0fd61a..cdb281ea 100644 --- a/modules/virtualan-plugin/src/main/java/io/virtualan/core/util/BestMatchComparator.java +++ b/modules/virtualan-plugin/src/main/java/io/virtualan/core/util/BestMatchComparator.java @@ -2,24 +2,23 @@ import java.util.Comparator; -import io.virtualan.core.model.VirtualServiceKeyValue; - public class BestMatchComparator implements Comparator { + + /** + * Orders mock responses best first: a mock whose every param matched the request ranks ahead + * of one that only partially matched, and within each group the mock with more matched params + * ranks first. Ties keep their original order (the sort is stable). + */ @Override public int compare(ReturnMockResponse returnMockResponse1, ReturnMockResponse returnMockResponse2) { - if (returnMockResponse2.getNumberAttrMatch() == returnMockResponse1.getNumberAttrMatch() - && isSame(returnMockResponse1, returnMockResponse2)) { - return 0; - } else if (returnMockResponse2.getNumberAttrMatch() == returnMockResponse1 - .getNumberAttrMatch() && isBestMatch(returnMockResponse1)) { - return -1; - } else if (returnMockResponse2.getNumberAttrMatch() >= returnMockResponse1 - .getNumberAttrMatch() && isBestMatch(returnMockResponse2)) { - return 1; - } else { - return -1; + boolean isBestMatch1 = isBestMatch(returnMockResponse1); + boolean isBestMatch2 = isBestMatch(returnMockResponse2); + if (isBestMatch1 != isBestMatch2) { + return isBestMatch1 ? -1 : 1; } + return Integer.compare(returnMockResponse2.getNumberAttrMatch(), + returnMockResponse1.getNumberAttrMatch()); } private boolean isBestMatch(ReturnMockResponse returnMockResponse) { @@ -28,37 +27,4 @@ private boolean isBestMatch(ReturnMockResponse returnMockResponse) { .size() == returnMockResponse.getNumberAttrMatch(); } - private boolean isSame(ReturnMockResponse returnMockResponse1, - ReturnMockResponse returnMockResponse2) { - boolean isSame = false; - if (isBestMatch(returnMockResponse1) && isBestMatch(returnMockResponse2) && - returnMockResponse1.getMockRequest().getAvailableParams() - .size() == returnMockResponse2.getMockRequest().getAvailableParams().size() - && returnMockResponse1.getNumberAttrMatch() == returnMockResponse2 - .getNumberAttrMatch()) { - isSame = true; - if (getMatch(returnMockResponse1, returnMockResponse2)) { - return false; - } - } - return isSame; - } - - private boolean getMatch(ReturnMockResponse returnMockResponse1, - ReturnMockResponse returnMockResponse2) { - for (final VirtualServiceKeyValue virtualServiceKeyValue1 : returnMockResponse1 - .getMockRequest().getAvailableParams()) { - for (final VirtualServiceKeyValue virtualServiceKeyValue2 : returnMockResponse2 - .getMockRequest().getAvailableParams()) { - if (!(virtualServiceKeyValue1.getKey() - .equals(virtualServiceKeyValue2.getKey()) - && virtualServiceKeyValue1.getValue() - .equals(virtualServiceKeyValue2.getValue()))) { - return true; - } - } - } - return false; - } - } diff --git a/modules/virtualan-plugin/src/test/java/io/virtualan/core/VirtualServiceUtilTest.java b/modules/virtualan-plugin/src/test/java/io/virtualan/core/VirtualServiceUtilTest.java new file mode 100644 index 00000000..12bc1f0c --- /dev/null +++ b/modules/virtualan-plugin/src/test/java/io/virtualan/core/VirtualServiceUtilTest.java @@ -0,0 +1,231 @@ +package io.virtualan.core; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.virtualan.api.VirtualServiceType; +import io.virtualan.core.model.MockRequest; +import io.virtualan.core.model.MockResponse; +import io.virtualan.core.model.MockServiceRequest; +import io.virtualan.core.model.VirtualServiceKeyValue; +import io.virtualan.core.model.VirtualServiceRequest; +import io.virtualan.core.util.ReturnMockResponse; +import io.virtualan.core.util.VirtualServiceParamComparator; +import io.virtualan.core.util.VirtualServiceValidRequest; +import io.virtualan.core.util.XMLConverter; +import io.virtualan.custom.message.ResponseException; +import io.virtualan.service.VirtualService; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.context.MessageSource; +import org.springframework.http.ResponseEntity; +import org.springframework.test.util.ReflectionTestUtils; + +class VirtualServiceUtilTest { + + interface PetApi { + ResponseEntity findPets(String status, String tag); + } + + private VirtualServiceUtil virtualServiceUtil; + private VirtualServiceValidRequest virtualServiceValidRequest; + private Method method; + + @BeforeEach + void setUp() throws Exception { + virtualServiceUtil = new VirtualServiceUtil(); + virtualServiceValidRequest = mock(VirtualServiceValidRequest.class); + VirtualService virtualService = mock(VirtualService.class); + when(virtualService.readByOperationId(any(), any())).thenReturn(Collections.emptyList()); + VirtualParameterizedUtil virtualParameterizedUtil = mock(VirtualParameterizedUtil.class); + when(virtualParameterizedUtil.getParameterizedResponse(any(), any())) + .thenReturn(new HashMap<>()); + when(virtualServiceValidRequest.validBusinessRuleForInputObject(any(), any())) + .thenReturn(new HashMap<>()); + when(virtualServiceValidRequest.checkScriptResponse(any(), any())) + .thenReturn(new HashMap<>()); + + ReflectionTestUtils.setField(virtualServiceUtil, "virtualServiceValidRequest", + virtualServiceValidRequest); + ReflectionTestUtils.setField(virtualServiceUtil, "virtualService", virtualService); + ReflectionTestUtils.setField(virtualServiceUtil, "xmlConverter", new XMLConverter()); + ReflectionTestUtils.setField(virtualServiceUtil, "objectMapper", new ObjectMapper()); + virtualServiceUtil.setVirtualParameterizedUtil(virtualParameterizedUtil); + method = PetApi.class.getMethod("findPets", String.class, String.class); + } + + private static ReturnMockResponse match(String output, int numberAttrMatch, + boolean exactMatch, String... params) { + List availableParams = new ArrayList<>(); + for (int i = 0; i < params.length; i += 2) { + availableParams.add(new VirtualServiceKeyValue(params[i], params[i + 1])); + } + MockRequest mockRequest = new MockRequest(); + mockRequest.setAvailableParams(availableParams); + ReturnMockResponse returnMockResponse = new ReturnMockResponse(); + returnMockResponse.setMockRequest(mockRequest); + returnMockResponse.setMockResponse(new MockResponse(output, "200")); + returnMockResponse.setNumberAttrMatch(numberAttrMatch); + returnMockResponse.setExactMatch(exactMatch); + return returnMockResponse; + } + + private Object respond(ReturnMockResponse... matches) throws Exception { + Map matchMap = new LinkedHashMap<>(); + for (int i = 0; i < matches.length; i++) { + matchMap.put(i + 1, matches[i]); + } + when(virtualServiceValidRequest.isResponseExists(any(), any())).thenReturn(matchMap); + MockServiceRequest mockServiceRequest = new MockServiceRequest(); + Map params = new HashMap<>(); + params.put("status", "available"); + params.put("tag", "x"); + mockServiceRequest.setParams(params); + mockServiceRequest.setHeaderParams(Collections.singletonMap("accept", "application/json")); + return virtualServiceUtil.returnResponse(method, mockServiceRequest); + } + + private static String body(Object response) { + return String.valueOf(((ResponseEntity) response).getBody()); + } + + // request: status=available&tag=x + // "exact" mock: status=available -> every mock param matches + // "partial" mock: status=available&tag=x&name=q -> 2 of its 3 params match + + @Test + void servesExactMatchWhenPartialMatchComesSecond() throws Exception { + Object response = respond( + match("exact", 1, true, "status", "available"), + match("partial", 2, false, "status", "available", "tag", "x", "name", "q")); + assertEquals("exact", body(response)); + } + + @Test + void servesExactMatchWhenPartialMatchComesFirst() throws Exception { + Object response = respond( + match("partial", 2, false, "status", "available", "tag", "x", "name", "q"), + match("exact", 1, true, "status", "available")); + assertEquals("exact", body(response)); + } + + @Test + void servesMostSpecificExactMatchRegardlessOfOrder() throws Exception { + ReturnMockResponse general = match("general", 1, true, "status", "available"); + ReturnMockResponse partial = + match("partial", 2, false, "status", "available", "tag", "x", "name", "q"); + ReturnMockResponse specific = + match("specific", 2, true, "status", "available", "tag", "x"); + List all = List.of(general, partial, specific); + for (List order : permutations(all)) { + Object response = respond(order.toArray(new ReturnMockResponse[0])); + assertEquals("specific", body(response), "order " + order); + } + } + + @Test + void servesExactMatchThatHasNoParamsAheadOfPartialMatch() throws Exception { + // an exact match with no params (e.g. matched on the request body) is not a full param + // match, so it does not sort first; it must still be the one served + Object response = respond( + match("partial", 2, false, "status", "available", "tag", "x", "name", "q"), + match("exact", 1, true)); + assertEquals("exact", body(response)); + } + + @Test + void servesNotFoundWhenThereIsNoExactMatch() throws Exception { + ReflectionTestUtils.setField(virtualServiceUtil, "virtualServiceType", + VirtualServiceType.SPRING); + MessageSource messageSource = mock(MessageSource.class); + when(messageSource.getMessage(any(), any(), any())).thenReturn("not found"); + ReflectionTestUtils.setField(virtualServiceUtil, "messageSource", messageSource); + ResponseException thrown = assertThrows(ResponseException.class, () -> respond( + match("partial", 2, false, "status", "available", "tag", "x", "name", "q"))); + assertEquals("not found", thrown.getResponseEntity().getBody()); + } + + @Test + void getResponseForListStillServesHeadOfList() throws Exception { + Object response = virtualServiceUtil.getResponse(method, Arrays.asList( + match("first", 1, true, "status", "available"), + match("second", 2, true, "status", "available", "tag", "x"))); + assertEquals("first", body(response)); + } + + private static VirtualServiceRequest storedMock(long id, String output, String... params) { + VirtualServiceRequest mock = new VirtualServiceRequest(); + mock.setId(id); + mock.setType("Response"); + mock.setHttpStatusCode("200"); + mock.setOutput(output); + List availableParams = new ArrayList<>(); + for (int i = 0; i < params.length; i += 2) { + availableParams.add(new VirtualServiceKeyValue(params[i], params[i + 1])); + } + mock.setAvailableParams(availableParams); + return mock; + } + + @Test + void servesExactMatchThroughRealMatcher() throws Exception { + VirtualServiceValidRequest realValidRequest = new VirtualServiceValidRequest(); + ReflectionTestUtils.setField(realValidRequest, "virtualServiceParamComparator", + new VirtualServiceParamComparator()); + ReflectionTestUtils.setField(realValidRequest, "objectMapper", new ObjectMapper()); + ReflectionTestUtils.setField(virtualServiceUtil, "virtualServiceValidRequest", + realValidRequest); + VirtualService virtualService = + (VirtualService) ReflectionTestUtils.getField(virtualServiceUtil, "virtualService"); + + // The mocks are held in a HashMap keyed by identity-hashed values, so the order the + // matcher sees them in changes from one load to the next; repeat to cover both orders. + for (int attempt = 0; attempt < 50; attempt++) { + when(virtualService.readByOperationId(any(), any())).thenReturn(Arrays.asList( + storedMock(1, "exact", "status", "available"), + storedMock(2, "partial", "status", "available", "tag", "x", "name", "q"))); + MockServiceRequest mockServiceRequest = new MockServiceRequest(); + Map params = new HashMap<>(); + params.put("status", "available"); + params.put("tag", "x"); + Map paramsType = new HashMap<>(); + paramsType.put("status", String.class); + paramsType.put("tag", String.class); + mockServiceRequest.setParams(params); + mockServiceRequest.setParamsType(paramsType); + mockServiceRequest.setHeaderParams(Collections.singletonMap("accept", "application/json")); + Object response = virtualServiceUtil.returnResponse(method, mockServiceRequest); + assertEquals("exact", body(response), "attempt " + attempt); + } + } + + private static List> permutations(List items) { + if (items.isEmpty()) { + List> result = new ArrayList<>(); + result.add(new ArrayList<>()); + return result; + } + List> result = new ArrayList<>(); + for (ReturnMockResponse item : items) { + List rest = new ArrayList<>(items); + rest.remove(item); + for (List tail : permutations(rest)) { + tail.add(0, item); + result.add(tail); + } + } + return result; + } +} diff --git a/modules/virtualan-plugin/src/test/java/io/virtualan/core/util/BestMatchComparatorTest.java b/modules/virtualan-plugin/src/test/java/io/virtualan/core/util/BestMatchComparatorTest.java new file mode 100644 index 00000000..e276522b --- /dev/null +++ b/modules/virtualan-plugin/src/test/java/io/virtualan/core/util/BestMatchComparatorTest.java @@ -0,0 +1,70 @@ +package io.virtualan.core.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import io.virtualan.core.model.MockRequest; +import io.virtualan.core.model.VirtualServiceKeyValue; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.Test; + +class BestMatchComparatorTest { + + private final BestMatchComparator comparator = new BestMatchComparator(); + + private static ReturnMockResponse match(String name, int numberAttrMatch, String... params) { + List availableParams = new ArrayList<>(); + for (int i = 0; i < params.length; i += 2) { + availableParams.add(new VirtualServiceKeyValue(params[i], params[i + 1])); + } + MockRequest mockRequest = new MockRequest(); + mockRequest.setAvailableParams(availableParams); + mockRequest.setRule(name); + ReturnMockResponse returnMockResponse = new ReturnMockResponse(); + returnMockResponse.setMockRequest(mockRequest); + returnMockResponse.setNumberAttrMatch(numberAttrMatch); + return returnMockResponse; + } + + private final ReturnMockResponse general = match("general", 1, "status", "available"); + private final ReturnMockResponse specific = + match("specific", 2, "status", "available", "tag", "x"); + private final ReturnMockResponse otherSpecific = + match("otherSpecific", 2, "status", "available", "name", "q"); + private final ReturnMockResponse partial = + match("partial", 2, "status", "available", "tag", "x", "name", "q"); + private final ReturnMockResponse weakPartial = + match("weakPartial", 1, "status", "available", "tag", "y"); + + @Test + void isAntisymmetric() { + List all = + Arrays.asList(general, specific, otherSpecific, partial, weakPartial); + for (ReturnMockResponse a : all) { + for (ReturnMockResponse b : all) { + assertEquals(Integer.signum(comparator.compare(a, b)), + -Integer.signum(comparator.compare(b, a)), + a.getMockRequest().getRule() + " vs " + b.getMockRequest().getRule()); + } + } + } + + @Test + void ranksFullMatchAheadOfPartialMatchWithMoreMatchedParams() { + assertEquals(-1, Integer.signum(comparator.compare(general, partial))); + assertEquals(1, Integer.signum(comparator.compare(partial, general))); + } + + @Test + void ranksMoreSpecificFullMatchFirst() { + assertEquals(1, Integer.signum(comparator.compare(general, specific))); + assertEquals(-1, Integer.signum(comparator.compare(specific, general))); + } + + @Test + void ranksPartialMatchesByMatchedParamCount() { + assertEquals(-1, Integer.signum(comparator.compare(partial, weakPartial))); + assertEquals(1, Integer.signum(comparator.compare(weakPartial, partial))); + } +}