diff --git a/README.md b/README.md index 6f6e77f4ae..de8eff3dd0 100644 --- a/README.md +++ b/README.md @@ -298,6 +298,7 @@ Name | Description `TEST_PROXY_USERNAME` | _(Optional)_ The username for a proxy to route all requests through `TEST_SKIPSSLVALIDATION` | _(Optional)_ Whether to skip SSL validation when connecting to the Cloud Foundry instance. Defaults to `false`. `UAA_API_REQUEST_LIMIT` | _(Optional)_ If your UAA server does rate limiting and returns 429 errors, set this variable to the smallest limit configured there. Whether your server limits UAA calls is shown in the log, together with the location of the configuration file on the server. Defaults to `0` (no limit). +`VERSION_MISMATCH_CONFIG` | _(Optional)_ If Cient and Cloud Foundry instance have different versions, it may happen that the response messages can not be parsed because of new properties that are not known to the client. The json file named in this environment variable allows to ignore such properties without failing the client. A sample configuration for ignoring any unknown property is available in file `versionMismatchConfigIgnoreAll.json`. If you do not have access to a CloudFoundry instance with admin access, you can run one locally using [bosh-deployment](https://github.com/cloudfoundry/bosh-deployment) & [cf-deployment](https://github.com/cloudfoundry/cf-deployment/) and Virtualbox. diff --git a/cloudfoundry-client-reactor/src/main/java/org/cloudfoundry/reactor/util/JsonCodec.java b/cloudfoundry-client-reactor/src/main/java/org/cloudfoundry/reactor/util/JsonCodec.java index 6cf55dce8e..6fdbe8a1ed 100644 --- a/cloudfoundry-client-reactor/src/main/java/org/cloudfoundry/reactor/util/JsonCodec.java +++ b/cloudfoundry-client-reactor/src/main/java/org/cloudfoundry/reactor/util/JsonCodec.java @@ -16,7 +16,9 @@ package org.cloudfoundry.reactor.util; +import com.fasterxml.jackson.core.JsonPointer; import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.annotation.JsonSerialize; import io.netty.handler.codec.http.HttpHeaderNames; @@ -25,7 +27,10 @@ import io.netty.handler.codec.json.JsonObjectDecoder; import java.nio.charset.Charset; import java.util.function.BiFunction; +import org.cloudfoundry.reactor.util.JsonDeserializationProblemHandler.RetryException; import org.reactivestreams.Publisher; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import reactor.core.Exceptions; import reactor.core.publisher.Mono; import reactor.netty.ByteBufFlux; @@ -35,6 +40,7 @@ public final class JsonCodec { private static final int MAX_PAYLOAD_SIZE = 100 * 1024 * 1024; + private static final Logger LOGGER = LoggerFactory.getLogger("cloudfoundry-client"); public static Mono decode( ObjectMapper objectMapper, ByteBufFlux responseBody, Class responseType) { @@ -43,17 +49,57 @@ public static Mono decode( .asByteArray() .map( payload -> { - try { - return objectMapper.readValue(payload, responseType); - } catch (Throwable t) { - throw new JsonParsingException( - t.getMessage(), - t, - new String(payload, Charset.defaultCharset())); - } + return decodeInternal(objectMapper, payload, responseType); }); } + // decode the payload into an object. + // If that fails, check the exception and retry. + private static T decodeInternal( + ObjectMapper objectMapper, byte[] payload, Class responseType) { + try { + return objectMapper.readValue(payload, responseType); + } catch (Throwable t) { + T result = null; + if (t instanceof JsonMappingException) { + Throwable cause = t.getCause(); + if (cause != null) { + if (cause instanceof RetryException) { + result = retry(objectMapper, responseType, (RetryException) cause, payload); + if (result != null) { + return result; + } + } + } + } + if (t instanceof RetryException) { + result = retry(objectMapper, responseType, (RetryException) t, payload); + if (result != null) { + return result; + } + } + String payloadStr = new String(payload, Charset.defaultCharset()); + LOGGER.warn("unable to parse the following json message:"); + LOGGER.warn(payloadStr); + throw new JsonParsingException(t.getMessage(), t, payloadStr); + } + } + + // drop the problematic element from the payload and try again. + private static T retry( + ObjectMapper objectMapper, + Class responseType, + RetryException cause, + byte[] payload) { + String property = cause.getProperty(); + JsonPointer pointer = cause.getJsonPointer(); // JsonPointer.compile(pointerStr); + payload = JsonDeserializationProblemHandler.dropProperty(payload, property, pointer); + if (payload != null) { + return decodeInternal(objectMapper, payload, responseType); + } + return null; + } + public static void setDecodeHeaders(HttpHeaders httpHeaders) { httpHeaders.set(HttpHeaderNames.ACCEPT, HttpHeaderValues.APPLICATION_JSON); } diff --git a/cloudfoundry-client-reactor/src/main/java/org/cloudfoundry/reactor/util/JsonDeserializationProblemHandler.java b/cloudfoundry-client-reactor/src/main/java/org/cloudfoundry/reactor/util/JsonDeserializationProblemHandler.java new file mode 100644 index 0000000000..36796088a1 --- /dev/null +++ b/cloudfoundry-client-reactor/src/main/java/org/cloudfoundry/reactor/util/JsonDeserializationProblemHandler.java @@ -0,0 +1,331 @@ +package org.cloudfoundry.reactor.util; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonPointer; +import com.fasterxml.jackson.core.JsonStreamContext; +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.deser.DeserializationProblemHandler; +import com.fasterxml.jackson.databind.deser.ValueInstantiator; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.io.IOException; +import java.nio.charset.Charset; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.HashSet; +import java.util.Set; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class JsonDeserializationProblemHandler extends DeserializationProblemHandler { + private static final Logger LOGGER = LoggerFactory.getLogger("cloudfoundry-client"); + private static Set propertiesToIgnore = new HashSet<>(); + + public static void addPropertyToIgnore(String className, String propertyName, String jsonPath) { + PropertyToIgnore oneEntry = + new PropertyToIgnore(className, propertyName, jsonPath.substring(1)); + propertiesToIgnore.add(oneEntry); + } + + /** + * only for unittests + */ + public static void flush() { + propertiesToIgnore = new HashSet<>(); + } + + @Override + public boolean handleUnknownProperty( + DeserializationContext ctxt, + JsonParser jp, + JsonDeserializer deserializer, + Object beanOrClass, + String propertyName) { + JsonPointer jsonPointer = toJsonPointer(jp); + Class rootType = + (beanOrClass instanceof Class) + ? (Class) beanOrClass + : (beanOrClass != null ? beanOrClass.getClass() : null); + String className = "unknown"; + if (rootType != null) { + className = rootType.getCanonicalName(); + } + LOGGER.info( + "Unknown property " + + propertyName + + " at " + + jsonPointer + + " while deserializing " + + className); + boolean shouldDelete = propertyShouldBeDroped(className, jsonPointer, propertyName); + if (shouldDelete) { + try { + jp.skipChildren(); + LOGGER.info( + "Ignoring property " + + propertyName + + " at " + + jsonPointer + + " as configured."); + } catch (IOException e) { + return false; + } + return true; + } + return false; + } + + public Object handleInstantiationProblem( + DeserializationContext ctxt, Class instClass, Object argument, Throwable t) + throws IOException { + JsonParser jp = ctxt.getParser(); + JsonPointer jsonPointer = toJsonPointer(jp); + String className = instClass.getCanonicalName(); + LOGGER.info( + "Unknown value " + + argument + + " at " + + jsonPointer + + " while deserializing " + + className); + boolean shouldDelete = propertyShouldBeDroped(className, jsonPointer, argument.toString()); + if (shouldDelete) { + LOGGER.info("Ignoring value " + argument + " at " + jsonPointer + " as configured."); + throw new RetryException(jsonPointer, argument.toString()); + } + return NOT_HANDLED; + } + + public Object handleUnexpectedToken( + DeserializationContext ctxt, + JavaType targetType, + JsonToken t, + JsonParser p, + String failureMsg) + throws IOException { + JsonPointer jsonPointer = toJsonPointer(p); + String className = targetType.toCanonical(); + LOGGER.info( + "Unknown Value " + + t.asString() + + " at " + + jsonPointer + + " while deserializing " + + className); + boolean shouldDelete = propertyShouldBeDroped(className, jsonPointer, t.asString()); + if (shouldDelete) { + LOGGER.info( + "Ignoring value " + t.asString() + " at " + jsonPointer + " as configured."); + throw new RetryException(jsonPointer, t.asString()); + } else { + jsonPointer = JsonPointer.compile(jsonPointer.toString() + "/" + t.asString()); + // Cleanup when Java11 is no longer supported. + // jsonPointer = jsonPointer .appendProperty(t.asString()); better, but not supported in + // old versions. + shouldDelete = propertyShouldBeDroped(className, jsonPointer, t.asString()); + if (shouldDelete) { + LOGGER.info( + "Ignoring value " + + t.asString() + + " at " + + jsonPointer + + " as configured."); + throw new RetryException(jsonPointer, t.asString()); + } + } + return NOT_HANDLED; + } + + public Object handleMissingInstantiator( + DeserializationContext ctxt, + Class instClass, + ValueInstantiator valueInsta, + JsonParser p, + String msg) + throws IOException { + JsonPointer jsonPointer = toJsonPointer(p); + String className = instClass.getTypeName(); + LOGGER.info( + "Unknown instantiator " + + p.currentName() + + " value " + + p.getText() + + " at " + + jsonPointer + + " while deserializing " + + className); + boolean shouldDelete = propertyShouldBeDroped(className, jsonPointer, p.currentName()); + if (shouldDelete) { + LOGGER.info("Ignoring value " + p.getText() + " at " + jsonPointer + " as configured."); + throw new RetryException(jsonPointer, p.getText()); + } else { + jsonPointer = JsonPointer.compile(jsonPointer.toString() + "/" + p.getText()); + // Cleanup when Java11 is no longer supported. + // jsonPointer = jsonPointer .appendProperty(p.getText()); better, but not supported in + // old versions. + shouldDelete = propertyShouldBeDroped(className, jsonPointer, p.currentName()); + if (shouldDelete) { + LOGGER.info( + "Ignoring value " + p.getText() + " at " + jsonPointer + " as configured."); + throw new RetryException(jsonPointer, p.getText()); + } + } + return NOT_HANDLED; + } + + // Get the pointer to the failing token from the parser. + private static JsonPointer toJsonPointer(JsonParser p) { + Deque segments = new ArrayDeque<>(); + JsonStreamContext ctx = p.getParsingContext(); + + while (ctx != null) { + if (ctx.inArray()) { + segments.push(String.valueOf(ctx.getCurrentIndex())); + } else if (ctx.inObject()) { + if (ctx.getCurrentName() != null) { + segments.push(ctx.getCurrentName()); + } + } + ctx = ctx.getParent(); + } + StringBuilder pointer = new StringBuilder(); + while (!segments.isEmpty()) { + pointer.append('/').append(segments.pop()); + } + return JsonPointer.compile(pointer.toString()); + } + + // check if the current values are listed in the configuration for properties to be ignored. + private static boolean propertyShouldBeDroped( + String className, JsonPointer pointer, String property) { + if (property == null) { + property = ""; + } + for (PropertyToIgnore oneProperty : propertiesToIgnore) { + if (className.matches(oneProperty.className.replace("*", ".*")) + && pointer.toString().matches("/" + oneProperty.jsonPath.replace("*", ".*")) + && property.matches(oneProperty.propertyName.replace("*", ".*"))) { + return true; + } + } + return false; + } + + /** + * Given a json byte array, find the named property at the given path and remove it. + * @param payload the byte array containing the json message. + * @param property the property or value that caused the parsing error. + * @param jsonPath the path where the property was found. + * @return a json string without the named property or null in case of any error. + */ + public static byte[] dropProperty(byte[] payload, String property, JsonPointer jsonPointer) { + JsonNode tree; + try { + tree = new ObjectMapper().readTree(payload); + } catch (IOException e) { + throw new JsonParsingException( + e.getMessage(), e, new String(payload, Charset.defaultCharset())); + } + JsonNode found = dropProperty(tree, jsonPointer, property); + if (found != null) { + return found.toString().getBytes(); + } + return null; + } + + // drop the property or value with the given name at the given pointer from the tree. If that + // fails, return null to indicate problem. + private static JsonNode dropProperty(JsonNode tree, JsonPointer pointer, String propertyName) { + JsonPointer parent = pointer.head(); + if ("".equals(parent.toString())) { + LOGGER.warn( + "parsing error in root element, can't delete " + + propertyName + + " at pointer " + + pointer); + + return null; // can't remove root + } + JsonNode parentNode = tree.at(parent); + if (parentNode.isMissingNode()) { + LOGGER.warn( + "cannot find and delete property " + propertyName + " at pointer " + pointer); + return null; + } + if (parentNode.isObject()) { + String lastToken = pointer.last().getMatchingProperty(); + JsonNode found = ((ObjectNode) parentNode).remove(lastToken); + if (found == null) { + LOGGER.warn( + "Cannot find and delete property " + + propertyName + + " at pointer " + + pointer); + return null; + } else { + LOGGER.info("ignoring " + pointer + " as configured"); + return tree; + } + } else { + if (parentNode.isArray()) { + int index = pointer.last().getMatchingIndex(); + if (index >= 0 && index < parentNode.size()) { + JsonNode found = ((ArrayNode) parentNode).remove(index); + if (found == null) { + LOGGER.warn( + "Cannot find and delete property " + + propertyName + + " at pointer " + + pointer); + return null; + } else { + LOGGER.info("ignoring " + pointer + " as configured"); + return tree; + } + } + } else { + // Invalid type in ValueNode, delete the complete node. e.g. true, when "true" is + // expected. + String nodename = parent.last().toString().replaceFirst("/", ""); + return dropProperty(tree, parent, nodename); + } + } + return null; + } + + private static class PropertyToIgnore { + public PropertyToIgnore(String className2, String propertyName2, String jsonPath2) { + className = className2; + propertyName = propertyName2; + jsonPath = jsonPath2; + } + + private String className; + private String jsonPath; + private String propertyName; + } + + public static class RetryException extends IOException { + private static final long serialVersionUID = 1L; + private JsonPointer jsonPointer; + private String property; + + public RetryException(JsonPointer jsonPointer, String property) { + this.jsonPointer = jsonPointer; + this.property = property; + } + + public JsonPointer getJsonPointer() { + return jsonPointer; + } + + public String getProperty() { + return property; + } + } +} diff --git a/cloudfoundry-client-reactor/src/main/resources/versionMismatchConfigIgnoreAll.json b/cloudfoundry-client-reactor/src/main/resources/versionMismatchConfigIgnoreAll.json new file mode 100644 index 0000000000..eea11aacd0 --- /dev/null +++ b/cloudfoundry-client-reactor/src/main/resources/versionMismatchConfigIgnoreAll.json @@ -0,0 +1,7 @@ +[ + { + "class": "*", + "pointer": "/*", + "property": "*" + } +] \ No newline at end of file diff --git a/cloudfoundry-client-reactor/src/test/java/org/cloudfoundry/reactor/util/JsonCodecTest.java b/cloudfoundry-client-reactor/src/test/java/org/cloudfoundry/reactor/util/JsonCodecTest.java new file mode 100644 index 0000000000..01266d2cc7 --- /dev/null +++ b/cloudfoundry-client-reactor/src/test/java/org/cloudfoundry/reactor/util/JsonCodecTest.java @@ -0,0 +1,212 @@ +package org.cloudfoundry.reactor.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.Duration; +import java.util.Map; +import org.cloudfoundry.uaa.clients.ListClientsResponse; +import org.cloudfoundry.uaa.identityzones.ListIdentityZonesResponse; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.netty.ByteBufFlux; +import reactor.test.StepVerifier; + +public class JsonCodecTest { + + private ObjectMapper objectMapper; + private ByteBufFlux body; + + @BeforeEach + void setup() throws IOException { + objectMapper = new ObjectMapper(); + objectMapper.addHandler(new JsonDeserializationProblemHandler()); + } + + @AfterEach + void tearDown() { + JsonDeserializationProblemHandler.flush(); + } + + @Test + void invalidProperyGetsReported() throws URISyntaxException { + URL fileUrl = + getClass() + .getResource("/fixtures/util/AddedProperty_ListIdentityZonesResponse.json"); + Path path = Paths.get(fileUrl.toURI()); + body = ByteBufFlux.fromPath(path); + StepVerifier.create(JsonCodec.decode(objectMapper, body, ListIdentityZonesResponse.class)) + .expectError(JsonParsingException.class) + .verify(Duration.ofSeconds(2)); + } + + @Test + void invalidProperyWithWrongIgnoreGetsReported() throws URISyntaxException { + JsonDeserializationProblemHandler.addPropertyToIgnore( + ListIdentityZonesResponse.class.getCanonicalName(), + "newPropertyNotKnownInType", + "/*/invalid/other"); + URL fileUrl = + getClass() + .getResource("/fixtures/util/AddedProperty_ListIdentityZonesResponse.json"); + Path path = Paths.get(fileUrl.toURI()); + body = ByteBufFlux.fromPath(path); + StepVerifier.create(JsonCodec.decode(objectMapper, body, ListIdentityZonesResponse.class)) + .expectError(JsonParsingException.class) + .verify(Duration.ofSeconds(2)); + } + + @Test + void invalidPropertyWrongTypeGetsReported() throws URISyntaxException { + URL fileUrl = + getClass() + .getResource("/fixtures/util/PropertyWrongType2_ListClientsResponse.json"); + Path path = Paths.get(fileUrl.toURI()); + body = ByteBufFlux.fromPath(path); + StepVerifier.create(JsonCodec.decode(objectMapper, body, ListClientsResponse.class)) + .expectError(JsonParsingException.class) + .verify(Duration.ofSeconds(2)); + } + + @Test + void invalidValueGetsReported() throws URISyntaxException { + JsonDeserializationProblemHandler.addPropertyToIgnore( + ListClientsResponse.class.getCanonicalName(), + "autoapprove", + "/resources/*/autoapprove"); + URL fileUrl = getClass().getResource("/fixtures/util/AddedValue_ListClientsResponse.json"); + Path path = Paths.get(fileUrl.toURI()); + body = ByteBufFlux.fromPath(path); + StepVerifier.create(JsonCodec.decode(objectMapper, body, ListClientsResponse.class)) + .expectError(JsonParsingException.class) + .verify(Duration.ofSeconds(2)); + } + + @Test + void invalidProperyGetsDeleted() throws URISyntaxException { + JsonDeserializationProblemHandler.addPropertyToIgnore( + "org.cloudfoundry.uaa.identityzones.IdentityZoneConfiguration.Json", + "newPropertyNotKnownInType", + "/0/config/newPropertyNotKnownInType"); + URL fileUrl = + getClass() + .getResource("/fixtures/util/AddedProperty_ListIdentityZonesResponse.json"); + Path path = Paths.get(fileUrl.toURI()); + body = ByteBufFlux.fromPath(path); + StepVerifier.create(JsonCodec.decode(objectMapper, body, ListIdentityZonesResponse.class)) + .assertNext(this::checkResponse1) + .verifyComplete(); + } + + @Test + void invalidProperyGetsDeletedWithWildcard() throws URISyntaxException { + JsonDeserializationProblemHandler.addPropertyToIgnore( + "org.cloudfoundry.uaa.identityzones.IdentityZoneConfiguration.Json", + "newPropertyNotKnownInType", + "/*/config/newPropertyNotKnownInType"); + URL fileUrl = + getClass() + .getResource("/fixtures/util/AddedProperty_ListIdentityZonesResponse.json"); + Path path = Paths.get(fileUrl.toURI()); + body = ByteBufFlux.fromPath(path); + StepVerifier.create(JsonCodec.decode(objectMapper, body, ListIdentityZonesResponse.class)) + .assertNext(this::checkResponse1) + .verifyComplete(); + } + + @Test + void invalidProperyFails() throws URISyntaxException { + URL fileUrl = + getClass().getResource("/fixtures/util/PropertyWrongType_ListClientsResponse.json"); + Path path = Paths.get(fileUrl.toURI()); + body = ByteBufFlux.fromPath(path); + StepVerifier.create(JsonCodec.decode(objectMapper, body, ListClientsResponse.class)) + .expectError(JsonParsingException.class) + .verify(Duration.ofSeconds(2)); + } + + @Test + void invalidProperyGetsDeleted3() throws URISyntaxException { + JsonDeserializationProblemHandler.addPropertyToIgnore( + "java.util.ArrayList", "true", "/resources/*/autoapprove/*"); + JsonDeserializationProblemHandler.addPropertyToIgnore( + "java.util.ArrayList", "autoapprove", "/resources/*/autoapprove/*"); + URL fileUrl = + getClass().getResource("/fixtures/util/PropertyWrongType_ListClientsResponse.json"); + Path path = Paths.get(fileUrl.toURI()); + body = ByteBufFlux.fromPath(path); + StepVerifier.create(JsonCodec.decode(objectMapper, body, ListClientsResponse.class)) + .assertNext(r -> checkResponse2(r, 1)) + .verifyComplete(); + } + + @Test + void invalidProperyGetsDeletedWildcardPropertyName() throws URISyntaxException { + JsonDeserializationProblemHandler.addPropertyToIgnore( + "java.util.ArrayList", "*", "/resources/*/autoapprove"); + JsonDeserializationProblemHandler.addPropertyToIgnore( + "java.util.ArrayList", "autoapprove", "/resources/*/autoapprove/*"); + URL fileUrl = + getClass().getResource("/fixtures/util/PropertyWrongType_ListClientsResponse.json"); + Path path = Paths.get(fileUrl.toURI()); + body = ByteBufFlux.fromPath(path); + StepVerifier.create(JsonCodec.decode(objectMapper, body, ListClientsResponse.class)) + .assertNext(r -> checkResponse2(r, 1)) + .verifyComplete(); + } + + @Test + void invalidProperyWrongTypeGetsDeleted() throws URISyntaxException { + JsonDeserializationProblemHandler.addPropertyToIgnore( + "java.util.ArrayList", "autoapprove", "/resources/*/autoapprove"); + URL fileUrl = + getClass() + .getResource("/fixtures/util/PropertyWrongType2_ListClientsResponse.json"); + Path path = Paths.get(fileUrl.toURI()); + body = ByteBufFlux.fromPath(path); + StepVerifier.create(JsonCodec.decode(objectMapper, body, ListClientsResponse.class)) + .assertNext(r -> checkResponse2(r, 1)) + .verifyComplete(); + } + + @Test + void invalidValueGetsDeleted12() throws URISyntaxException { + JsonDeserializationProblemHandler.addPropertyToIgnore( + "org.cloudfoundry.uaa.tokens.GrantType", + "grant_type_that_is_not_known", + "/resources/*/authorized_grant_types/*"); + URL fileUrl = getClass().getResource("/fixtures/util/AddedValue_ListClientsResponse.json"); + Path path = Paths.get(fileUrl.toURI()); + body = ByteBufFlux.fromPath(path); + StepVerifier.create(JsonCodec.decode(objectMapper, body, ListClientsResponse.class)) + .assertNext(r -> checkResponse2(r, 1)) + .verifyComplete(); + } + + private void checkResponse1(ListIdentityZonesResponse response) { + assertEquals(1, response.getIdentityZones().size()); + } + + private void checkResponse2(ListClientsResponse response, int expectedValue) { + assertEquals(expectedValue, response.getResources().size()); + } + + @Test + void invalidRootElementFails() throws URISyntaxException { + JsonDeserializationProblemHandler.addPropertyToIgnore( + "java.util.LinkedHashMap", "*", "/String"); + JsonDeserializationProblemHandler.addPropertyToIgnore("java.util.LinkedHashMap", "*", "/"); + URL fileUrl = getClass().getResource("/fixtures/util/AddedRoot_ListClientsResponse.json"); + Path path = Paths.get(fileUrl.toURI()); + body = ByteBufFlux.fromPath(path); + StepVerifier.create(JsonCodec.decode(objectMapper, body, Map.class)) + .expectError(JsonParsingException.class) + .verify(Duration.ofSeconds(2)); + } +} diff --git a/cloudfoundry-client-reactor/src/test/resources/fixtures/util/AddedProperty_ListIdentityZonesResponse.json b/cloudfoundry-client-reactor/src/test/resources/fixtures/util/AddedProperty_ListIdentityZonesResponse.json new file mode 100644 index 0000000000..254afad7fe --- /dev/null +++ b/cloudfoundry-client-reactor/src/test/resources/fixtures/util/AddedProperty_ListIdentityZonesResponse.json @@ -0,0 +1,12 @@ +[ + { + "id": "uaa", + "subdomain": "", + "config": { + "newPropertyNotKnownInType": "" + }, + "name": "uaa", + "created": 946684800000, + "last_modified": 1759840807651 + } +] diff --git a/cloudfoundry-client-reactor/src/test/resources/fixtures/util/AddedRoot_ListClientsResponse.json b/cloudfoundry-client-reactor/src/test/resources/fixtures/util/AddedRoot_ListClientsResponse.json new file mode 100644 index 0000000000..6c27f89045 --- /dev/null +++ b/cloudfoundry-client-reactor/src/test/resources/fixtures/util/AddedRoot_ListClientsResponse.json @@ -0,0 +1 @@ +"String" \ No newline at end of file diff --git a/cloudfoundry-client-reactor/src/test/resources/fixtures/util/AddedValue_ListClientsResponse.json b/cloudfoundry-client-reactor/src/test/resources/fixtures/util/AddedValue_ListClientsResponse.json new file mode 100644 index 0000000000..1dc30aa790 --- /dev/null +++ b/cloudfoundry-client-reactor/src/test/resources/fixtures/util/AddedValue_ListClientsResponse.json @@ -0,0 +1,15 @@ +{ + "resources": [ + { + "authorized_grant_types": [ + "password", + "grant_type_that_is_not_known", + "refresh_token" + ], + "client_id": "cf" + } + ], + "startIndex": 1, + "itemsPerPage": 11, + "totalResults": 11 +} \ No newline at end of file diff --git a/cloudfoundry-client-reactor/src/test/resources/fixtures/util/PropertyWrongType2_ListClientsResponse.json b/cloudfoundry-client-reactor/src/test/resources/fixtures/util/PropertyWrongType2_ListClientsResponse.json new file mode 100644 index 0000000000..2c3dd4f9f7 --- /dev/null +++ b/cloudfoundry-client-reactor/src/test/resources/fixtures/util/PropertyWrongType2_ListClientsResponse.json @@ -0,0 +1,11 @@ +{ + "resources": [ + { + "client_id": "cf_smoke_tests", + "autoapprove": "true" + } + ], + "startIndex": 1, + "itemsPerPage": 37, + "totalResults": 37 +} diff --git a/cloudfoundry-client-reactor/src/test/resources/fixtures/util/PropertyWrongType_ListClientsResponse.json b/cloudfoundry-client-reactor/src/test/resources/fixtures/util/PropertyWrongType_ListClientsResponse.json new file mode 100644 index 0000000000..0431c07641 --- /dev/null +++ b/cloudfoundry-client-reactor/src/test/resources/fixtures/util/PropertyWrongType_ListClientsResponse.json @@ -0,0 +1,11 @@ +{ + "resources": [ + { + "client_id": "cf_smoke_tests", + "autoapprove": true + } + ], + "startIndex": 1, + "itemsPerPage": 37, + "totalResults": 37 +} diff --git a/integration-test/src/test/java/org/cloudfoundry/IntegrationTestConfiguration.java b/integration-test/src/test/java/org/cloudfoundry/IntegrationTestConfiguration.java index 36c30c3578..80feb53d09 100644 --- a/integration-test/src/test/java/org/cloudfoundry/IntegrationTestConfiguration.java +++ b/integration-test/src/test/java/org/cloudfoundry/IntegrationTestConfiguration.java @@ -16,20 +16,21 @@ package org.cloudfoundry; -import static org.assertj.core.api.Assertions.fail; import static org.cloudfoundry.uaa.tokens.GrantType.AUTHORIZATION_CODE; import static org.cloudfoundry.uaa.tokens.GrantType.CLIENT_CREDENTIALS; import static org.cloudfoundry.uaa.tokens.GrantType.PASSWORD; import static org.cloudfoundry.uaa.tokens.GrantType.REFRESH_TOKEN; import static org.cloudfoundry.util.tuple.TupleUtils.function; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.JsonDeserializer; -import com.fasterxml.jackson.databind.deser.DeserializationProblemHandler; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import com.github.zafarkhaja.semver.Version; import java.io.IOException; +import java.net.URISyntaxException; +import java.net.URL; import java.nio.file.Path; +import java.nio.file.Paths; import java.security.SecureRandom; import java.time.Duration; import java.util.Arrays; @@ -63,6 +64,7 @@ import org.cloudfoundry.reactor.tokenprovider.ClientCredentialsGrantTokenProvider; import org.cloudfoundry.reactor.tokenprovider.PasswordGrantTokenProvider; import org.cloudfoundry.reactor.uaa.ReactorUaaClient; +import org.cloudfoundry.reactor.util.JsonDeserializationProblemHandler; import org.cloudfoundry.routing.RoutingClient; import org.cloudfoundry.uaa.UaaClient; import org.cloudfoundry.uaa.clients.CreateClientRequest; @@ -250,6 +252,7 @@ String clientSecret(NameFactory nameFactory) { } @Bean + @DependsOn("configureVersionMismatchhandling") CloudFoundryCleaner cloudFoundryCleaner( @Qualifier("admin") CloudFoundryClient cloudFoundryClient, NameFactory nameFactory, @@ -260,6 +263,54 @@ CloudFoundryCleaner cloudFoundryCleaner( cloudFoundryClient, nameFactory, networkingClient, serverVersion, uaaClient); } + @Bean + Boolean configureVersionMismatchhandling( + @Value("${version.mismatch.config:#{null}}") String fileName) + throws URISyntaxException, IOException { + if (fileName != null) { + URL url = getClass().getResource("/" + fileName); + if (url != null) { + Path path = Paths.get(url.toURI()); + ObjectMapper mapper = new ObjectMapper(); + logger.info( + "reading configuration for handling unknown properties from server from" + + " file " + + path.toString()); + List config = + mapper.readValue( + path.toFile(), + new TypeReference< + List< + IntegrationTestConfiguration + .VersionMismatchHandlingEntry>>() {}); + handleConfig(config); + } else { + logger.error("cannot load config file " + fileName); + } + } + return false; + } + + private static class VersionMismatchHandlingEntry { + @JsonProperty("class") + private String className; + + @JsonProperty("property") + private String property; + + @JsonProperty("pointer") + private String pointer; + } + + private Boolean handleConfig(List config) { + config.forEach( + oneEntry -> { + JsonDeserializationProblemHandler.addPropertyToIgnore( + oneEntry.className, oneEntry.property, oneEntry.pointer); + }); + return true; + } + @Bean ReactorCloudFoundryClient cloudFoundryClient( ConnectionContext connectionContext, TokenProvider tokenProvider) { @@ -306,9 +357,7 @@ DefaultConnectionContext connectionContext( DefaultConnectionContext.Builder connectionContext = DefaultConnectionContext.builder() .apiHost(apiHost) - .problemHandler( - new FailingDeserializationProblemHandler()) // Test-only problem - // handler + .problemHandler(new JsonDeserializationProblemHandler()) .skipSslValidation(skipSslValidation) .sslHandshakeTimeout(Duration.ofSeconds(30)); @@ -745,22 +794,4 @@ Mono userId(@Qualifier("admin") UaaClient uaaClient, String password, St String username(NameFactory nameFactory) { return nameFactory.getUserName(); } - - public static final class FailingDeserializationProblemHandler - extends DeserializationProblemHandler { - - @Override - public boolean handleUnknownProperty( - DeserializationContext ctxt, - JsonParser jp, - JsonDeserializer deserializer, - Object beanOrClass, - String propertyName) { - fail( - String.format( - "Found unexpected property %s in payload for %s", - propertyName, beanOrClass.getClass().getName())); - return false; - } - } } diff --git a/integration-test/src/test/java/org/cloudfoundry/uaa/RatelimitTestConfiguration.java b/integration-test/src/test/java/org/cloudfoundry/uaa/RatelimitTestConfiguration.java index e162955e4d..eb5341200a 100644 --- a/integration-test/src/test/java/org/cloudfoundry/uaa/RatelimitTestConfiguration.java +++ b/integration-test/src/test/java/org/cloudfoundry/uaa/RatelimitTestConfiguration.java @@ -1,13 +1,13 @@ package org.cloudfoundry.uaa; import java.time.Duration; -import org.cloudfoundry.IntegrationTestConfiguration.FailingDeserializationProblemHandler; import org.cloudfoundry.ThrottlingUaaClient; import org.cloudfoundry.reactor.ConnectionContext; import org.cloudfoundry.reactor.DefaultConnectionContext; import org.cloudfoundry.reactor.ProxyConfiguration; import org.cloudfoundry.reactor.tokenprovider.ClientCredentialsGrantTokenProvider; import org.cloudfoundry.reactor.uaa.ReactorUaaClient; +import org.cloudfoundry.reactor.util.JsonDeserializationProblemHandler; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.context.annotation.Bean; @@ -52,7 +52,7 @@ DefaultConnectionContext connectionContext( DefaultConnectionContext.builder() .apiHost(apiHost) .problemHandler( - new FailingDeserializationProblemHandler()) // Test-only problem + new JsonDeserializationProblemHandler()) // Test-only problem // handler .skipSslValidation(skipSslValidation) .sslHandshakeTimeout(Duration.ofSeconds(30)); diff --git a/integration-test/src/test/resources/versionMismatchConfig.json b/integration-test/src/test/resources/versionMismatchConfig.json new file mode 100644 index 0000000000..9fac8d695b --- /dev/null +++ b/integration-test/src/test/resources/versionMismatchConfig.json @@ -0,0 +1,12 @@ +[ + { + "class": "org.cloudfoundry.uaa.tokens.GrantType", + "pointer": "/resources/*/authorized_grant_types/*", + "property": "urn:ietf:params:oauth:grant-type:jwt-bearer" + }, + { + "class": "org.cloudfoundry.uaa.identityzones.IdentityZoneConfiguration.Json", + "pointer": "/0/config/defaultIdentityProvider", + "property": "defaultIdentityProvider" + } +] \ No newline at end of file