diff --git a/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/config/NormalizedOperationMethod.java b/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/config/NormalizedOperationMethod.java index ed373eaff..277c6e247 100644 --- a/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/config/NormalizedOperationMethod.java +++ b/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/config/NormalizedOperationMethod.java @@ -17,6 +17,7 @@ package org.springframework.cloud.gateway.server.mvc.config; import java.lang.reflect.Method; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -104,13 +105,42 @@ private Map normalizeArgs(Map operationArgs) { map.put(fieldName, StringUtils.collectionToCommaDelimitedString(operationArgs.values())); yield map; } - default -> throw new IllegalArgumentException("Unknown Shortcut type " + shortcut.type()); + case LIST_TAIL_FLAG -> normalizeListTailFlag(operationArgs, fieldOrder); }; } } return operationArgs; } + private static Map normalizeListTailFlag(Map operationArgs, String[] fieldOrder) { + // field order: list field name, then optional boolean tail flag. + // Unlike reactive GATHER_LIST_TAIL_FLAG (omits/nulls the flag when absent), + // always + // emit false so a primitive boolean param binds safely for the MVC invoker. + Assert.isTrue(fieldOrder != null && fieldOrder.length == 2, + "Shortcut Configuration Type LIST_TAIL_FLAG must have shortcutFieldOrder of size 2"); + List values = new ArrayList<>(operationArgs.values()); + Object flagValue = Boolean.FALSE; + if (!values.isEmpty()) { + int lastIdx = values.size() - 1; + Object lastValue = values.get(lastIdx); + if (lastValue == null) { + values = values.subList(0, lastIdx); + } + else { + String lastString = lastValue.toString(); + if ("true".equalsIgnoreCase(lastString) || "false".equalsIgnoreCase(lastString)) { + values = values.subList(0, lastIdx); + flagValue = Boolean.valueOf(lastString); + } + } + } + Map map = new HashMap<>(); + map.put(fieldOrder[0], new ArrayList<>(values)); + map.put(fieldOrder[1], flagValue); + return map; + } + private String[] getFieldOrder(Shortcut shortcut) { String[] fieldOrder = shortcut.fieldOrder(); if (fieldOrder.length == 0) { diff --git a/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/FilterFunctions.java b/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/FilterFunctions.java index d21ce9010..ec8ce1bce 100644 --- a/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/FilterFunctions.java +++ b/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/FilterFunctions.java @@ -17,11 +17,13 @@ package org.springframework.cloud.gateway.server.mvc.filter; import java.net.URI; +import java.util.List; import java.util.function.Consumer; import org.springframework.cloud.gateway.server.mvc.common.HttpStatusHolder; import org.springframework.cloud.gateway.server.mvc.common.KeyValues; import org.springframework.cloud.gateway.server.mvc.common.Shortcut; +import org.springframework.cloud.gateway.server.mvc.common.Shortcut.Type; import org.springframework.cloud.gateway.server.mvc.filter.AfterFilterFunctions.DedupeStrategy; import org.springframework.cloud.gateway.server.mvc.filter.BeforeFilterFunctions.FallbackHeadersConfig; import org.springframework.http.HttpHeaders; @@ -148,6 +150,21 @@ static HandlerFilterFunction removeResponseHeade return ofResponseProcessor(AfterFilterFunctions.removeResponseHeader(name)); } + /** + * YAML / properties shortcut for + * {@link AfterFilterFunctions#removeJsonAttributesResponseBody(List, boolean)}. + * Supports {@code RemoveJsonAttributesResponseBody=id,color} and an optional trailing + * boolean for recursive deletion ({@code ...,true}). + * @param fieldList JSON attribute names to remove + * @param deleteRecursively whether to remove nested attributes + * @return the filter function + */ + @Shortcut(type = Type.LIST_TAIL_FLAG, fieldOrder = { "fieldList", "deleteRecursively" }) + static HandlerFilterFunction removeJsonAttributesResponseBody( + List fieldList, boolean deleteRecursively) { + return ofResponseProcessor(AfterFilterFunctions.removeJsonAttributesResponseBody(fieldList, deleteRecursively)); + } + @Shortcut static HandlerFilterFunction requestHeaderSize(String maxSize) { return ofRequestProcessor(BeforeFilterFunctions.requestHeaderSize(maxSize)); diff --git a/spring-cloud-gateway-server-webmvc/src/test/java/org/springframework/cloud/gateway/server/mvc/config/NormalizedOperationMethodTests.java b/spring-cloud-gateway-server-webmvc/src/test/java/org/springframework/cloud/gateway/server/mvc/config/NormalizedOperationMethodTests.java new file mode 100644 index 000000000..69c5acc2b --- /dev/null +++ b/spring-cloud-gateway-server-webmvc/src/test/java/org/springframework/cloud/gateway/server/mvc/config/NormalizedOperationMethodTests.java @@ -0,0 +1,107 @@ +/* + * Copyright 2013-present 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.server.mvc.config; + +import java.lang.reflect.Method; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import org.springframework.cloud.gateway.server.mvc.common.NameUtils; +import org.springframework.cloud.gateway.server.mvc.common.Shortcut; +import org.springframework.cloud.gateway.server.mvc.common.Shortcut.Type; +import org.springframework.cloud.gateway.server.mvc.invoke.reflect.DefaultOperationMethod; +import org.springframework.web.servlet.function.HandlerFilterFunction; +import org.springframework.web.servlet.function.ServerResponse; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Burak Kalayci + */ +class NormalizedOperationMethodTests { + + @Test + void listTailFlagWithoutTrailingBooleanKeepsFieldsAndDefaultsFlagFalse() throws Exception { + Method method = SampleFilters.class.getDeclaredMethod("removeJsonAttributesResponseBody", List.class, + boolean.class); + Map args = new LinkedHashMap<>(); + args.put(NameUtils.generateName(0), "id"); + args.put(NameUtils.generateName(1), "color"); + + NormalizedOperationMethod operationMethod = new NormalizedOperationMethod(new DefaultOperationMethod(method), + args); + Map normalized = operationMethod.getNormalizedArgs(); + + assertThat(normalized).containsOnlyKeys("fieldList", "deleteRecursively"); + assertThat(normalized.get("fieldList")).isInstanceOf(List.class); + @SuppressWarnings("unchecked") + List fieldList = (List) normalized.get("fieldList"); + assertThat(fieldList).containsExactly("id", "color"); + assertThat(normalized.get("deleteRecursively")).isEqualTo(Boolean.FALSE); + } + + @Test + void listTailFlagWithTrailingBooleanStripsFlagFromFieldList() throws Exception { + Method method = SampleFilters.class.getDeclaredMethod("removeJsonAttributesResponseBody", List.class, + boolean.class); + Map args = new LinkedHashMap<>(); + args.put(NameUtils.generateName(0), "id"); + args.put(NameUtils.generateName(1), "color"); + args.put(NameUtils.generateName(2), "true"); + + NormalizedOperationMethod operationMethod = new NormalizedOperationMethod(new DefaultOperationMethod(method), + args); + Map normalized = operationMethod.getNormalizedArgs(); + + @SuppressWarnings("unchecked") + List fieldList = (List) normalized.get("fieldList"); + assertThat(fieldList).containsExactly("id", "color"); + assertThat(normalized.get("deleteRecursively")).isEqualTo(Boolean.TRUE); + } + + @Test + void listTailFlagIgnoresNonBooleanTrailingValue() throws Exception { + Method method = SampleFilters.class.getDeclaredMethod("removeJsonAttributesResponseBody", List.class, + boolean.class); + Map args = new LinkedHashMap<>(); + args.put(NameUtils.generateName(0), "id"); + args.put(NameUtils.generateName(1), "notAFlag"); + + NormalizedOperationMethod operationMethod = new NormalizedOperationMethod(new DefaultOperationMethod(method), + args); + Map normalized = operationMethod.getNormalizedArgs(); + + @SuppressWarnings("unchecked") + List fieldList = (List) normalized.get("fieldList"); + assertThat(fieldList).containsExactly("id", "notAFlag"); + assertThat(normalized.get("deleteRecursively")).isEqualTo(Boolean.FALSE); + } + + static final class SampleFilters { + + @Shortcut(type = Type.LIST_TAIL_FLAG, fieldOrder = { "fieldList", "deleteRecursively" }) + static HandlerFilterFunction removeJsonAttributesResponseBody( + List fieldList, boolean deleteRecursively) { + return (request, next) -> next.handle(request); + } + + } + +} diff --git a/spring-cloud-gateway-server-webmvc/src/test/java/org/springframework/cloud/gateway/server/mvc/config/RemoveJsonAttributesResponseBodyShortcutTests.java b/spring-cloud-gateway-server-webmvc/src/test/java/org/springframework/cloud/gateway/server/mvc/config/RemoveJsonAttributesResponseBodyShortcutTests.java new file mode 100644 index 000000000..df8263d1d --- /dev/null +++ b/spring-cloud-gateway-server-webmvc/src/test/java/org/springframework/cloud/gateway/server/mvc/config/RemoveJsonAttributesResponseBodyShortcutTests.java @@ -0,0 +1,108 @@ +/* + * Copyright 2013-present 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.server.mvc.config; + +import java.util.Map; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.cloud.gateway.server.mvc.test.HttpbinTestcontainers; +import org.springframework.cloud.gateway.server.mvc.test.PermitAllSecurityConfiguration; +import org.springframework.cloud.gateway.server.mvc.test.TestLoadBalancerConfig; +import org.springframework.cloud.loadbalancer.annotation.LoadBalancerClient; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.web.servlet.client.RestTestClient; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.cloud.gateway.server.mvc.test.TestUtils.getMap; + +/** + * End-to-end coverage for the YAML/properties shortcut path of + * {@code RemoveJsonAttributesResponseBody} (registration → invoke → live filter), which + * is the failure mode reported in gh-4240. + * + * @author Burak Kalayci + */ +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +@ActiveProfiles("removejsonattributesshortcuttests") +@ContextConfiguration(initializers = HttpbinTestcontainers.class) +class RemoveJsonAttributesResponseBodyShortcutTests { + + @Autowired + RestTestClient restClient; + + @BeforeAll + static void beforeAll() { + HttpbinTestcontainers.initializeSystemProperties(); + } + + @Test + @SuppressWarnings("unchecked") + void shortcutWithoutFlagBuildsRouteAndStripsRootAttributes() { + restClient.get().uri("/get").exchange().expectStatus().isOk().expectBody(Map.class).consumeWith(res -> { + Map body = res.getResponseBody(); + assertThat(body).isNotNull(); + // shortcut resolved (no "Unable to find operation") and filter applied + assertThat(body).doesNotContainKeys("origin", "url"); + assertThat(body).containsKey("headers"); + }); + } + + @Test + @SuppressWarnings("unchecked") + void shortcutWithTrailingTrueBuildsRouteAndStripsNestedAttributes() { + restClient.post() + .uri("/post") + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .header("Foo", "remove-me") + .header("Bar", "keep-me") + .body("{}") + .exchange() + .expectStatus() + .isOk() + .expectBody(Map.class) + .consumeWith(res -> { + Map body = res.getResponseBody(); + assertThat(body).isNotNull(); + Map headers = getMap(body, "headers"); + assertThat(headers).isNotNull(); + // recursive true: nested headers.Foo removed (httpbin preserves this + // casing) + assertThat(headers).doesNotContainKey("Foo"); + assertThat(headers).containsEntry("Bar", "keep-me"); + }); + } + + @SpringBootConfiguration + @EnableAutoConfiguration + @LoadBalancerClient(name = "httpbin", configuration = TestLoadBalancerConfig.Httpbin.class) + @Import(PermitAllSecurityConfiguration.class) + static class Config { + + } + +} diff --git a/spring-cloud-gateway-server-webmvc/src/test/resources/application-removejsonattributesshortcuttests.yml b/spring-cloud-gateway-server-webmvc/src/test/resources/application-removejsonattributesshortcuttests.yml new file mode 100644 index 000000000..f169ac522 --- /dev/null +++ b/spring-cloud-gateway-server-webmvc/src/test/resources/application-removejsonattributesshortcuttests.yml @@ -0,0 +1,21 @@ +httpbin.base: http://${httpbin.host}:${httpbin.port} +spring.cloud.gateway.server.webmvc: + function: + enabled: false + routes: + - id: removeJsonAttrsNoFlag + uri: ${httpbin.base} + predicates: + - Path=/get + filters: + - RemoveJsonAttributesResponseBody=origin,url + - id: removeJsonAttrsWithFlag + uri: ${httpbin.base} + predicates: + - Path=/post + filters: + - RemoveJsonAttributesResponseBody=Foo,true + +logging: + level: + org.springframework.cloud.gateway.server.mvc: INFO