From a487e64bfd5edd66eb6b6e5f9fe85ec6a4984c0a Mon Sep 17 00:00:00 2001 From: Marta Medio Date: Wed, 5 Oct 2022 17:39:48 +0200 Subject: [PATCH] Add remove json attributes filter (#2742) * Add RemoveJsonAttributesFilterFactory Takes a collection of attribute names to search for to remove from a JSON response, an optional last parameter from the list can be a boolean to remove the attributes just at root level or recursively * Add docs for RemoveJsonAttributes filter * Extract a single instance of Json mapper * Overload boolean parameter to avoid parsing a string --- .../main/asciidoc/spring-cloud-gateway.adoc | 45 ++++++ .../config/GatewayAutoConfiguration.java | 10 ++ ...butesResponseBodyGatewayFilterFactory.java | 147 ++++++++++++++++++ .../route/builder/GatewayFilterSpec.java | 14 ++ ...ResponseBodyGatewayFilterFactoryTests.java | 119 ++++++++++++++ 5 files changed, 335 insertions(+) create mode 100644 spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/RemoveJsonAttributesResponseBodyGatewayFilterFactory.java create mode 100644 spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/RemoveJsonAttributesResponseBodyGatewayFilterFactoryTests.java diff --git a/docs/src/main/asciidoc/spring-cloud-gateway.adoc b/docs/src/main/asciidoc/spring-cloud-gateway.adoc index 2f8c2edb..c2a24f1b 100644 --- a/docs/src/main/asciidoc/spring-cloud-gateway.adoc +++ b/docs/src/main/asciidoc/spring-cloud-gateway.adoc @@ -1118,6 +1118,51 @@ spring: This will send a status 302 with a `Location:https://acme.org` header to perform a redirect. +=== `RemoveJsonAttributesResponseBody` `GatewayFilter` Factory + +The `RemoveJsonAttributesResponseBody` `GatewayFilter` factory takes a collection of `attribute names` to search for, an optional last parameter from the list can be a boolean to remove the attributes just at root level (that's the default value if not present at the end of the parameter configuration, `false`) or recursively (`true`). +It provides a convenient method to apply a transformation to JSON body content by deleting attributes from it. + +The following example configures an `RemoveJsonAttributesResponseBody` `GatewayFilter`: + +.application.yml +==== +[source,yaml] +---- +spring: + cloud: + gateway: + routes: + - id: removejsonattributes_route + uri: https://example.org + filters: + - RemoveJsonAttributesResponseBody=id,color +---- +==== + +This removes attributes "id" and "color" from the JSON content body at root level. + +The following example configures an `RemoveJsonAttributesResponseBody` `GatewayFilter` that uses the optional last parameter: + +.application.yml +==== +[source,yaml] +---- +spring: + cloud: + gateway: + routes: + - id: removejsonattributes_recursively_route + uri: https://example.org + predicates: + - Path=/red/{segment} + filters: + - RemoveJsonAttributesResponseBody=id,color,true +---- +==== + +This removes attributes "id" and "color" from the JSON content body at any level. + === The `RemoveRequestHeader` GatewayFilter Factory The `RemoveRequestHeader` `GatewayFilter` factory takes a `name` parameter. diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java index 3b224a5c..7a60a402 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java @@ -79,6 +79,7 @@ import org.springframework.cloud.gateway.filter.factory.MapRequestHeaderGatewayF import org.springframework.cloud.gateway.filter.factory.PrefixPathGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.PreserveHostHeaderGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.RedirectToGatewayFilterFactory; +import org.springframework.cloud.gateway.filter.factory.RemoveJsonAttributesResponseBodyGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.RemoveRequestHeaderGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.RemoveRequestParameterGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.RemoveResponseHeaderGatewayFilterFactory; @@ -549,6 +550,15 @@ public class GatewayAutoConfiguration { return new RedirectToGatewayFilterFactory(); } + @Bean + @ConditionalOnEnabledFilter + public RemoveJsonAttributesResponseBodyGatewayFilterFactory removeJsonAttributesResponseBodyGatewayFilterFactory( + ServerCodecConfigurer codecConfigurer, Set bodyDecoders, + Set bodyEncoders) { + return new RemoveJsonAttributesResponseBodyGatewayFilterFactory( + new ModifyResponseBodyGatewayFilterFactory(codecConfigurer.getReaders(), bodyDecoders, bodyEncoders)); + } + @Bean @ConditionalOnEnabledFilter public RemoveRequestHeaderGatewayFilterFactory removeRequestHeaderGatewayFilterFactory() { diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/RemoveJsonAttributesResponseBodyGatewayFilterFactory.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/RemoveJsonAttributesResponseBodyGatewayFilterFactory.java new file mode 100644 index 00000000..09558702 --- /dev/null +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/RemoveJsonAttributesResponseBodyGatewayFilterFactory.java @@ -0,0 +1,147 @@ +/* + * Copyright 2013-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 org.springframework.cloud.gateway.filter.factory; + +import java.util.Arrays; +import java.util.List; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import reactor.core.publisher.Mono; + +import org.springframework.cloud.gateway.filter.GatewayFilter; +import org.springframework.cloud.gateway.filter.factory.rewrite.ModifyResponseBodyGatewayFilterFactory; +import org.springframework.cloud.gateway.filter.factory.rewrite.RewriteFunction; +import org.springframework.core.style.ToStringCreator; +import org.springframework.http.MediaType; + +/** + * @author Marta Medio + */ +public class RemoveJsonAttributesResponseBodyGatewayFilterFactory extends + AbstractGatewayFilterFactory { + + public RemoveJsonAttributesResponseBodyGatewayFilterFactory( + ModifyResponseBodyGatewayFilterFactory modifyResponseBodyGatewayFilterFactory) { + this.modifyResponseBodyGatewayFilterFactory = modifyResponseBodyGatewayFilterFactory; + } + + @Override + public ShortcutType shortcutType() { + return ShortcutType.GATHER_LIST_TAIL_FLAG; + } + + @Override + public List shortcutFieldOrder() { + return Arrays.asList("fieldList", "deleteRecursively"); + } + + @Override + public FieldListConfiguration newConfig() { + return new FieldListConfiguration(); + } + + @Override + public Class getConfigClass() { + return FieldListConfiguration.class; + } + + @Override + public GatewayFilter apply(FieldListConfiguration config) { + ModifyResponseBodyGatewayFilterFactory.Config modifyResponseBodyConfig = new ModifyResponseBodyGatewayFilterFactory.Config(); + modifyResponseBodyConfig.setInClass(String.class); + modifyResponseBodyConfig.setOutClass(String.class); + + RewriteFunction rewriteFunction = (exchange, body) -> { + if (MediaType.APPLICATION_JSON.isCompatibleWith(exchange.getResponse().getHeaders().getContentType())) { + try { + JsonNode jsonBodyContent = mapper.readValue(body, JsonNode.class); + + removeJsonAttribute(jsonBodyContent, config.getFieldList(), config.isDeleteRecursively()); + + body = mapper.writeValueAsString(jsonBodyContent); + } + catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + return Mono.just(body); + }; + modifyResponseBodyConfig.setRewriteFunction(rewriteFunction); + + return modifyResponseBodyGatewayFilterFactory.apply(modifyResponseBodyConfig); + } + + private final ModifyResponseBodyGatewayFilterFactory modifyResponseBodyGatewayFilterFactory; + + private ObjectMapper mapper = new ObjectMapper(); + + private void removeJsonAttribute(JsonNode jsonBodyContent, List fieldsToRemove, boolean deleteRecursively) { + if (deleteRecursively) { + for (JsonNode jsonNode : jsonBodyContent) { + if (jsonNode instanceof ObjectNode) { + ((ObjectNode) jsonNode).remove(fieldsToRemove); + removeJsonAttribute(jsonNode, fieldsToRemove, true); + } + if (jsonNode instanceof ArrayNode) { + for (JsonNode node : jsonNode) { + removeJsonAttribute(node, fieldsToRemove, true); + } + } + } + } + if (jsonBodyContent instanceof ObjectNode) { + ((ObjectNode) jsonBodyContent).remove(fieldsToRemove); + } + } + + public static class FieldListConfiguration { + + private List fieldList; + + private boolean deleteRecursively; + + public boolean isDeleteRecursively() { + return deleteRecursively; + } + + public FieldListConfiguration setDeleteRecursively(boolean deleteRecursively) { + this.deleteRecursively = deleteRecursively; + return this; + } + + List getFieldList() { + return fieldList; + } + + public FieldListConfiguration setFieldList(List fieldList) { + this.fieldList = fieldList; + return this; + } + + @Override + public String toString() { + return new ToStringCreator(this).append("fieldList", fieldList) + .append("deleteRecursively", deleteRecursively).toString(); + } + + } + +} diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/route/builder/GatewayFilterSpec.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/route/builder/GatewayFilterSpec.java index 8d23d423..02915681 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/route/builder/GatewayFilterSpec.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/route/builder/GatewayFilterSpec.java @@ -51,6 +51,7 @@ import org.springframework.cloud.gateway.filter.factory.MapRequestHeaderGatewayF import org.springframework.cloud.gateway.filter.factory.PrefixPathGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.PreserveHostHeaderGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.RedirectToGatewayFilterFactory; +import org.springframework.cloud.gateway.filter.factory.RemoveJsonAttributesResponseBodyGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.RemoveRequestHeaderGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.RemoveRequestParameterGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.RemoveResponseHeaderGatewayFilterFactory; @@ -464,6 +465,19 @@ public class GatewayFilterSpec extends UriSpec { } } + /** + * A filter that can be used to modify the response body. + * @param attributes list of attributes to remove separated by commas, an optional + * last parameter from the list can be a boolean to remove the attributes just at root + * level (false) o recursively (true) + * @return a {@link GatewayFilterSpec} that can be used to apply additional filters + */ + public GatewayFilterSpec removeJsonAttributes(boolean deleteRecursively, String... attributes) { + return filter(getBean(RemoveJsonAttributesResponseBodyGatewayFilterFactory.class) + .apply(c -> c.setFieldList(Arrays.asList(attributes)).setDeleteRecursively(deleteRecursively))); + + } + /** * A filter that will remove a request header before the request is routed by the * Gateway. diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/RemoveJsonAttributesResponseBodyGatewayFilterFactoryTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/RemoveJsonAttributesResponseBodyGatewayFilterFactoryTests.java new file mode 100644 index 00000000..ad2a5b41 --- /dev/null +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/RemoveJsonAttributesResponseBodyGatewayFilterFactoryTests.java @@ -0,0 +1,119 @@ +/* + * Copyright 2013-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 org.springframework.cloud.gateway.filter.factory; + +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.gateway.route.RouteLocator; +import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder; +import org.springframework.cloud.gateway.test.BaseWebClientTests; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.annotation.DirtiesContext; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; +import static org.springframework.cloud.gateway.test.TestUtils.getMap; + +/** + * @author Marta Medio + */ +@SpringBootTest(webEnvironment = RANDOM_PORT) +@DirtiesContext +public class RemoveJsonAttributesResponseBodyGatewayFilterFactoryTests extends BaseWebClientTests { + + @Test + public void removeJsonAttributeRootWorks() { + testClient.post().uri("/post").header("Host", "www.removejsonattributes.org") + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE).header("foo", "test") + .header("bar", "test").exchange().expectStatus().isOk().expectBody(Map.class).consumeWith(result -> { + Map response = result.getResponseBody(); + assertThat(response).isNotNull(); + + String responseBody = (String) response.get("data"); + assertThat(responseBody).isNull(); + + Map headers = getMap(response, "headers"); + assertThat(headers).containsKey("user-agent"); + + }); + } + + @Test + public void removeJsonAttributeRecursivelyWorks() { + + testClient.post().uri("/post").header("Host", "www.removejsonattributesrecursively.org") + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE).header("foo", "test") + .header("bar", "test").exchange().expectStatus().isOk().expectBody(Map.class).consumeWith(result -> { + Map response = result.getResponseBody(); + assertThat(response).isNotNull(); + + Map headers = getMap(response, "headers"); + assertThat(headers).doesNotContainKey("foo"); + assertThat(headers).containsEntry("bar", "test"); + }); + } + + @Test + public void removeJsonAttributeNoMatchesWorks() { + + testClient.post().uri("/post").header("Host", "www.removejsonattributesnomatches.org") + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE).exchange().expectStatus().isOk() + .expectBody(Map.class).consumeWith(result -> { + Map response = result.getResponseBody(); + assertThat(response).isNotNull(); + + Map headers = getMap(response, "headers"); + assertThat(headers).isNotNull(); + assertThat(headers).containsEntry(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE); + }); + } + + @EnableAutoConfiguration + @SpringBootConfiguration + @Import(DefaultTestConfig.class) + public static class TestConfig { + + @Value("${test.uri}") + String uri; + + @Bean + public RouteLocator testRouteLocator(RouteLocatorBuilder builder) { + return builder.routes() + .route("remove_json_attributes_root_level_java_test", + r -> r.path("/post").and().host("{sub}.removejsonattributes.org") + .filters(f -> f.removeJsonAttributes(false, "data", "foo")).uri(uri)) + .route("remove_json_attributes_recursively_java_test", + r -> r.path("/post").and().host("{sub}.removejsonattributesrecursively.org") + .filters(f -> f.removeJsonAttributes(true, "foo")).uri(uri)) + .route("remove_json_attributes_no_matches_java_test", + r -> r.path("/post").and().host("{sub}.removejsonattributesnomatches.org") + .filters(f -> f.removeJsonAttributes(false, "test")).uri(uri)) + .build(); + } + + } + +}