From 52e4e93e85a498b58b40cf936ac0d4bc2d8adf33 Mon Sep 17 00:00:00 2001 From: Etienne Bratschi Date: Tue, 7 May 2024 14:56:05 +0200 Subject: [PATCH] feat: add json array order configuration option in java Signed-off-by: Etienne Bratschi --- .../json/JsonMessageValidationContext.java | 56 +++++++-- .../validation/json/JsonElementValidator.java | 52 ++++++-- .../json/JsonElementValidatorTest.java | 118 +++++++++++++----- 3 files changed, 175 insertions(+), 51 deletions(-) diff --git a/core/citrus-base/src/main/java/org/citrusframework/validation/json/JsonMessageValidationContext.java b/core/citrus-base/src/main/java/org/citrusframework/validation/json/JsonMessageValidationContext.java index 79a50785bf..83b9fed86d 100644 --- a/core/citrus-base/src/main/java/org/citrusframework/validation/json/JsonMessageValidationContext.java +++ b/core/citrus-base/src/main/java/org/citrusframework/validation/json/JsonMessageValidationContext.java @@ -16,38 +16,53 @@ package org.citrusframework.validation.json; -import java.util.HashSet; -import java.util.List; -import java.util.Set; - +import jakarta.annotation.Nullable; import org.citrusframework.validation.context.DefaultValidationContext; import org.citrusframework.validation.context.SchemaValidationContext; import org.citrusframework.validation.context.ValidationContext; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + /** * Validation context holding JSON specific validation information. + * * @author Christoph Deppisch * @since 2.3 */ public class JsonMessageValidationContext extends DefaultValidationContext implements SchemaValidationContext { - /** Map holding xpath expressions to identify the ignored message elements */ + /** + * Map holding xpath expressions to identify the ignored message elements + */ private final Set ignoreExpressions; /** * Should message be validated with its schema definition - * + *

* This is currently disabled by default, because old json tests would fail with a validation exception * as soon as a json schema repository is specified and the schema validation is activated. */ private final boolean schemaValidation; - /** Explicit schema repository to use for this validation */ + /** + * Explicit schema repository to use for this validation + */ private final String schemaRepository; - /** Explicit schema instance to use for this validation */ + /** + * Explicit schema instance to use for this validation + */ private final String schema; + /** + * Whether the ordering of arrays should be validated or not. If this property is not explicitly set, then + * {@code true} will be assumed if the system-wide validation mode is {@code strict}, and {@code false} if otherwise. + */ + @Nullable + private final Boolean checkArrayOrder; + /** * Default constructor. */ @@ -57,6 +72,7 @@ public JsonMessageValidationContext() { /** * Constructor using fluent builder. + * * @param builder */ public JsonMessageValidationContext(Builder builder) { @@ -64,6 +80,7 @@ public JsonMessageValidationContext(Builder builder) { this.schemaValidation = builder.schemaValidation; this.schemaRepository = builder.schemaRepository; this.schema = builder.schema; + this.checkArrayOrder = builder.checkArrayOrder; } /** @@ -76,6 +93,7 @@ public static final class Builder implements ValidationContext.Builder paths) { return this; } + /** + * Sets whether array order should be validated for this message. + * + * @param checkArrayOrder whether array order is checked + * @return this builder for chaining + */ + public Builder checkArrayOrder(final boolean checkArrayOrder) { + this.checkArrayOrder = checkArrayOrder; + return this; + } + @Override public JsonMessageValidationContext build() { return new JsonMessageValidationContext(this); @@ -153,6 +182,7 @@ public JsonMessageValidationContext build() { /** * Get ignored message elements. + * * @return the ignoreExpressions */ public Set getIgnoreExpressions() { @@ -173,4 +203,14 @@ public String getSchemaRepository() { public String getSchema() { return schema; } + + /** + * Get whether the array order should be considered in validation. + * + * @return whether the array order is checked + */ + @Nullable + public Boolean shouldCheckArrayOrder() { + return checkArrayOrder; + } } diff --git a/validation/citrus-validation-json/src/main/java/org/citrusframework/validation/json/JsonElementValidator.java b/validation/citrus-validation-json/src/main/java/org/citrusframework/validation/json/JsonElementValidator.java index 87612e5c28..0d1099de57 100644 --- a/validation/citrus-validation-json/src/main/java/org/citrusframework/validation/json/JsonElementValidator.java +++ b/validation/citrus-validation-json/src/main/java/org/citrusframework/validation/json/JsonElementValidator.java @@ -35,15 +35,26 @@ public class JsonElementValidator { private final boolean strict; private final TestContext context; private final Collection ignoreExpressions; + private final Boolean checkArrayOrder; public JsonElementValidator( boolean strict, TestContext context, Collection ignoreExpressions + ) { + this(strict, context, ignoreExpressions, null); + } + + public JsonElementValidator( + boolean strict, + TestContext context, + Collection ignoreExpressions, + Boolean checkArrayOrder ) { this.strict = strict; this.context = context; this.ignoreExpressions = ignoreExpressions; + this.checkArrayOrder = checkArrayOrder; } public void validate(JsonElementValidatorItem control) { @@ -96,11 +107,7 @@ static boolean isIgnoredByPlaceholderOrExpressionList(Collection ignoreE return true; } - if (ignoreExpressions.stream().anyMatch(controlEntry::isPathIgnoredBy)) { - return true; - } - - return false; + return ignoreExpressions.stream().anyMatch(controlEntry::isPathIgnoredBy); } @@ -109,17 +116,40 @@ private void validateJSONArray(JsonElementValidator validator, JsonElementValida if (strict) { validateSameSize(control.getJsonPath(), arrayControl.expected, arrayControl.actual); } + + boolean doCheckArrayOrder = requireNonNullElse(checkArrayOrder, strict); for (int i = 0; i < arrayControl.expected.size(); i++) { - if (!isAnyValidItemInActualArray(validator, arrayControl, i)) { - throw new ValidationException(buildValueToBeInCollectionErrorMessage( - "An item in '%s' is missing".formatted(arrayControl.getJsonPath()), - arrayControl.expected.get(i), - arrayControl.actual - )); + if (doCheckArrayOrder) { + checkExactArrayElementPosition(validator, arrayControl, i); + } else { + if (!isAnyValidItemInActualArray(validator, arrayControl, i)) { //TODO ignores element count - intended? + throw new ValidationException(buildValueToBeInCollectionErrorMessage( + "An item in '%s' is missing".formatted(arrayControl.getJsonPath()), + arrayControl.expected.get(i), + arrayControl.actual + )); + } } } } + private void checkExactArrayElementPosition(JsonElementValidator validator, JsonElementValidatorItem control, int index) { + try { + var itemControl = new JsonElementValidatorItem<>( + index, + control.actual.get(index), + control.expected.get(index) + ).parent(control); + validator.validate(itemControl); + } catch (ValidationException e) { + throw new ValidationException(buildValueMismatchErrorMessage( + "Elements not equal for array '%s' at position %d".formatted(control.getJsonPath(), index), + control.expected.get(index), + control.actual.get(index) + )); + } + } + private boolean isAnyValidItemInActualArray(JsonElementValidator validator, JsonElementValidatorItem control, int index) { Object expectedItem = control.expected.get(index); return control.actual.stream().map(recivedItem -> { diff --git a/validation/citrus-validation-json/src/test/java/org/citrusframework/validation/json/JsonElementValidatorTest.java b/validation/citrus-validation-json/src/test/java/org/citrusframework/validation/json/JsonElementValidatorTest.java index 236354f70a..24cddd6ada 100644 --- a/validation/citrus-validation-json/src/test/java/org/citrusframework/validation/json/JsonElementValidatorTest.java +++ b/validation/citrus-validation-json/src/test/java/org/citrusframework/validation/json/JsonElementValidatorTest.java @@ -16,7 +16,6 @@ package org.citrusframework.validation.json; -import org.assertj.core.api.AbstractThrowableAssert; import org.citrusframework.UnitTestSupport; import org.citrusframework.exceptions.ValidationException; import org.testng.annotations.DataProvider; @@ -34,6 +33,9 @@ public class JsonElementValidatorTest extends UnitTestSupport { public static final boolean NOT_STRICT = false; public static final boolean STRICT = true; + public static final boolean CHECK_ARRAY_ORDER = true; + public static final boolean DO_NOT_CHECK_ARRAY_ORDER = false; + JsonElementValidator fixture; @Test(dataProvider = "validJsonPairsIfNotStrict") @@ -128,10 +130,10 @@ public static JsonAssertion[] validIfStrict() { ), new JsonAssertion( "[1, 2, 3]", - "[3, 2, 1]" + "[1, 2, 3]" ), new JsonAssertion( - "{ \"books\": [\"book-c\", \"book-b\", \"book-a\"] }", + "{ \"books\": [\"book-a\", \"book-b\", \"book-c\"] }", "{ \"books\": [\"book-a\", \"book-b\", \"book-c\"] }" ) ).toArray(new JsonAssertion[0]); @@ -139,10 +141,12 @@ public static JsonAssertion[] validIfStrict() { @Test(dataProvider = "invalidJsonPairs") - public AbstractThrowableAssert shouldBeInvalid(JsonAssertion jsonAssertion) { + public void shouldBeInvalid(JsonAssertion jsonAssertion) { var validationItem = toValidationItem(jsonAssertion); fixture = new JsonElementValidator(STRICT, context, Set.of()); - return assertThatThrownBy(() -> fixture.validate(validationItem)).isInstanceOf(ValidationException.class); + assertThatThrownBy(() -> fixture.validate(validationItem)) + .isInstanceOf(ValidationException.class) + .hasMessageContainingAll(jsonAssertion.messageContains()); } @DataProvider @@ -151,70 +155,58 @@ public static JsonAssertion[] invalidJsonPairs() { new JsonAssertion( "{\"myNumbers\": [11, 22, 44]}", "{\"myNumbers\": [11, 22, 33]}", - "An item in '$['myNumbers']' is missing, expected '33' to be in '[11,22,44]'" - ), + "Elements not equal for array '$['myNumbers']' at position 2, expected '33' but was '44'"), new JsonAssertion( "{\"text\":\"Hello World!\", \"index\":5, \"id\":\"x123456789x\"}", "{\"text\":\"Hello World!\", \"index\":5, \"id\":\"x123456789x\", \"missing\":\"this is missing\"}", "Number of entries is not equal in element: '$'", - "expected '[missing, index, text, id]' but was '[index, text, id]'" - ), + "expected '[missing, index, text, id]' but was '[index, text, id]'"), new JsonAssertion( "{\"greetings\":[{\"text\":\"Hello World!\", \"index\":1}, {\"text\":\"Hallo Welt!\", \"index\":0}, {\"text\":\"Hola del mundo!\", \"index\":3}], \"id\":\"x123456789x\"}", "{\"greetings\":[{\"text\":\"Hello World!\", \"index\":1}, {\"text\":\"Hallo Welt!\", \"index\":2}, {\"text\":\"Hola del mundo!\", \"index\":3}], \"id\":\"x123456789x\"}", - "An item in '$['greetings']' is missing, expected '{\"index\":2,\"text\":\"Hallo Welt!\"}' to be in '[{\"index\":1,\"text\":\"Hello World!\"},{\"index\":0,\"text\":\"Hallo Welt!\"},{\"index\":3,\"text\":\"Hola del mundo!\"}]'" - ), + "Elements not equal for array '$['greetings']' at position 1, expected '{\"index\":2,\"text\":\"Hallo Welt!\"}' but was '{\"index\":0,\"text\":\"Hallo Welt!\"}'"), new JsonAssertion( "{\"numbers\":[101, 42]}", "{\"numbers\":[101, 42, 9000]}", "Number of entries is not equal in element: '$['numbers']'", - "expected '[101,42,9000]' but was '[101,42]'" - ), + "expected '[101,42,9000]' but was '[101,42]'"), new JsonAssertion( "{\"test\": \"Lorem\"}", "{\"test\": \"@equalsIgnoreCase('lorem ipsum')@\"}", - "EqualsIgnoreCaseValidationMatcher failed for field 'test'", - "Received value is 'Lorem', control value is 'lorem ipsum'" - ), + "EqualsIgnoreCaseValidationMatcher failed for field '$['test']'", + "Received value is 'Lorem'", "control value is 'lorem ipsum'."), new JsonAssertion( "{\"not-test\": \"lorem\"}", "{\"test\": \"lorem\"}", - "Missing JSON entry, expected 'test' to be in '[not-test]'" - ), + "Missing JSON entry, expected 'test' to be in '[not-test]'"), new JsonAssertion( "{\"greetings\":[{\"text\":\"Hello World!\", \"index\":1}, {\"text\":\"Hallo Welt!\", \"index\":2}, {\"text\":\"Hola del mundo!\", \"index\":3}], \"id\":\"x123456789x\"}", "{\"greetings\":{\"text\":\"Hello World!\", \"index\":1}, \"id\":\"x123456789x\"}", "expected 'JSONObject'", - "but was 'JSONArray'" - ), + "but was 'JSONArray'"), new JsonAssertion( "{\"text\":\"Hello World!\", \"index\":5, \"id\":\"x123456789x\"}", "{\"text\":\"Hello World!\", \"index\":5, \"id\":null}", - "expected 'null' but was 'x123456789x'" - ), + "expected 'null' but was 'x123456789x'"), new JsonAssertion( "{\"text\":\"Hello World!\", \"index\":5, \"id\":\"wrong\"}", "{\"text\":\"Hello World!\", \"index\":5, \"id\":\"x123456789x\"}", "expected 'x123456789x'", - "but was 'wrong'" - ), + "but was 'wrong'"), new JsonAssertion( "{\"text\":\"Hello World!\", \"person\":{\"name\":\"John\",\"surname\":\"wrong\"}, \"index\":5, \"id\":\"x123456789x\"}", "{\"text\":\"Hello World!\", \"person\":{\"name\":\"John\",\"surname\":\"Doe\"}, \"index\":5, \"id\":\"x123456789x\"}", "expected 'Doe'", - "but was 'wrong'" - ), + "but was 'wrong'"), new JsonAssertion( "{\"greetings\":{\"text\":\"Hello World!\", \"index\":1}, \"id\":\"x123456789x\"}", "{\"greetings\":[{\"text\":\"Hello World!\", \"index\":1}, {\"text\":\"Hallo Welt!\", \"index\":2}, {\"text\":\"Hola del mundo!\", \"index\":3}], \"id\":\"x123456789x\"}", "expected 'JSONArray'", - "but was 'JSONObject'" - ), + "but was 'JSONObject'"), new JsonAssertion( - "", - "{\"text\":\"Hello World!\", \"index\":5, \"id\":\"x123456789x\"}", - "expected message contents, but received empty message" - ) + "[\"a\", \"c\", \"b\"]", + "[\"a\", \"b\", \"c\"]", + "Elements not equal for array '$' at position 1, expected 'b' but was 'c'") ).toArray(new JsonAssertion[0]); } @@ -258,6 +250,64 @@ public static JsonAssertion[] validOnlyWithIgnoreExpressions() { ).toArray(new JsonAssertion[0]); } + @Test + void validate_shouldCheckArrayOrder_whenPropertyTrue() { + var validItem = toValidationItem(new JsonAssertion( + "[\"a\", \"b\", \"c\"]", + "[\"a\", \"b\", \"c\"]")); + + fixture = new JsonElementValidator(STRICT, context, Set.of(), true); + assertThatNoException().isThrownBy(() -> fixture.validate(validItem)); + + var invalidItem = toValidationItem(new JsonAssertion( + "[\"a\", \"b\", \"c\"]", + "[\"c\", \"a\", \"b\"]")); + + fixture = new JsonElementValidator(STRICT, context, Set.of(), true); + assertThatThrownBy(() -> fixture.validate(invalidItem)) + .isInstanceOf(ValidationException.class) + .hasMessageContaining("Elements not equal for array"); + } + + @Test + void validate_shouldNotCheckArrayOrder_whenPropertyFalse() { + var invalidItem = toValidationItem(new JsonAssertion( + "[\"a\", \"b\", \"c\"]", + "[\"c\", \"a\", \"b\"]")); + + fixture = new JsonElementValidator(STRICT, context, Set.of(), false); + assertThatNoException().isThrownBy(() -> fixture.validate(invalidItem)); + } + + @Test + void validate_shouldCheckArrayOrder_whenPropertyNotSet_andStrict() { + var validItem = toValidationItem(new JsonAssertion( + "[\"a\", \"b\", \"c\"]", + "[\"a\", \"b\", \"c\"]")); + + fixture = new JsonElementValidator(STRICT, context, Set.of()); + assertThatNoException().isThrownBy(() -> fixture.validate(validItem)); + + var invalidItem = toValidationItem(new JsonAssertion( + "[\"a\", \"b\", \"c\"]", + "[\"c\", \"a\", \"b\"]")); + + fixture = new JsonElementValidator(STRICT, context, Set.of()); + assertThatThrownBy(() -> fixture.validate(invalidItem)) + .isInstanceOf(ValidationException.class) + .hasMessageContaining("Elements not equal for array"); + } + + @Test + void validate_shouldNotCheckArrayOrder_whenPropertyNotSet_andNotStrict() { + var validationItem = toValidationItem(new JsonAssertion( + "[\"a\", \"b\", \"c\"]", + "[\"c\", \"a\", \"b\"]")); + + fixture = new JsonElementValidator(NOT_STRICT, context, Set.of()); + assertThatNoException().isThrownBy(() -> fixture.validate(validationItem)); + } + private static JsonElementValidatorItem toValidationItem(JsonAssertion jsonAssertion) { return JsonElementValidatorItem.parseJson(DEFAULT_PERMISSIVE_MODE, jsonAssertion.actual, jsonAssertion.expected); } @@ -268,6 +318,10 @@ private record JsonAssertion( Set ignoreExpressions, String... messageContains ) { + public JsonAssertion(String actual, String expected) { + this(actual, expected, (String[]) null); + } + public JsonAssertion(String actual, String expected, String... messageContains) { this(actual, expected, Set.of(), messageContains); }