diff --git a/spring-web/src/main/java/org/springframework/web/filter/reactive/HiddenHttpMethodFilter.java b/spring-web/src/main/java/org/springframework/web/filter/reactive/HiddenHttpMethodFilter.java new file mode 100644 index 00000000000..de0f19a9df9 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/filter/reactive/HiddenHttpMethodFilter.java @@ -0,0 +1,97 @@ +/* + * Copyright 2017 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 + * + * http://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.web.filter.reactive; + +import org.springframework.http.HttpMethod; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.util.Assert; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilter; +import org.springframework.web.server.WebFilterChain; +import reactor.core.publisher.Mono; + +import java.util.Locale; +import java.util.Optional; + +/** + * Reactive {@link WebFilter} that converts posted method parameters into HTTP methods, + * retrievable via {@link ServerHttpRequest#getMethod()}. Since browsers currently only + * support GET and POST, a common technique - used by the Prototype library, for instance - + * is to use a normal POST with an additional hidden form field ({@code _method}) + * to pass the "real" HTTP method along. This filter reads that parameter and changes + * the {@link ServerHttpRequest#getMethod()} return value using {@link ServerWebExchange#mutate()}. + * + *

The name of the request parameter defaults to {@code _method}, but can be + * adapted via the {@link #setMethodParam(String) methodParam} property. + * + * @author Greg Turnquist + * @since 5.0 + */ +public class HiddenHttpMethodFilter implements WebFilter { + + /** Default method parameter: {@code _method} */ + public static final String DEFAULT_METHOD_PARAM = "_method"; + + private String methodParam = DEFAULT_METHOD_PARAM; + + /** + * Set the parameter name to look for HTTP methods. + * @see #DEFAULT_METHOD_PARAM + */ + public void setMethodParam(String methodParam) { + Assert.hasText(methodParam, "'methodParam' must not be empty"); + this.methodParam = methodParam; + } + + /** + * Transform an HTTP POST into another method based on {@code methodParam} + * + * @param exchange the current server exchange + * @param chain provides a way to delegate to the next filter + * @return + */ + @Override + public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { + + if (exchange.getRequest().getMethod() == HttpMethod.POST) { + return exchange.getFormData() + .map(map -> Optional.ofNullable(map.getFirst(methodParam))) + .map(method -> convertedRequest(exchange, method)) + .then(convertedExchange -> chain.filter(convertedExchange)); + } else { + return chain.filter(exchange); + } + } + + /** + * Mutate exchange into a new HTTP method. + * + * @param exchange - original request + * @param method - request HTTP method based on form data + * @return a mutated {@link ServerWebExchange} + */ + private ServerWebExchange convertedRequest(ServerWebExchange exchange, Optional method) { + + String upperMethod = method + .map(String::toString) + .orElse(HttpMethod.POST.toString()) + .toUpperCase(Locale.ENGLISH); + + return exchange.mutate() + .request(builder -> builder.method(HttpMethod.resolve(upperMethod))) + .build(); + } +} diff --git a/spring-web/src/test/java/org/springframework/web/filter/reactive/HiddenHttpMethodFilterTests.java b/spring-web/src/test/java/org/springframework/web/filter/reactive/HiddenHttpMethodFilterTests.java new file mode 100644 index 00000000000..84af27f7417 --- /dev/null +++ b/spring-web/src/test/java/org/springframework/web/filter/reactive/HiddenHttpMethodFilterTests.java @@ -0,0 +1,119 @@ +/* + * Copyright 2017 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 + * + * http://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.web.filter.reactive; + +import org.junit.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.mock.http.server.reactive.test.MockServerHttpRequest; +import org.springframework.mock.http.server.reactive.test.MockServerHttpResponse; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilterChain; +import org.springframework.web.server.adapter.DefaultServerWebExchange; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.util.Optional; + +import static org.junit.Assert.*; + +/** + * @author Greg Turnquist + */ +public class HiddenHttpMethodFilterTests { + + private final HiddenHttpMethodFilter filter = new HiddenHttpMethodFilter(); + + @Test + public void filterWithParameter() { + ServerWebExchange mockExchange = createExchange(Optional.of("DELETE")); + + WebFilterChain filterChain = exchange -> { + assertEquals("Invalid method", HttpMethod.DELETE, exchange.getRequest().getMethod()); + return Mono.empty(); + }; + + StepVerifier.create(filter.filter(mockExchange, filterChain)) + .expectComplete() + .verify(); + } + + @Test + public void filterWithNoParameter() { + ServerWebExchange mockExchange = createExchange(Optional.empty()); + + WebFilterChain filterChain = exchange -> { + assertEquals("Invalid method", HttpMethod.POST, exchange.getRequest().getMethod()); + return Mono.empty(); + }; + + StepVerifier.create(filter.filter(mockExchange, filterChain)) + .expectComplete() + .verify(); + } + + @Test + public void filterWithDifferentMethodParam() { + ServerWebExchange mockExchange = createExchange("_foo", Optional.of("DELETE")); + + WebFilterChain filterChain = exchange -> { + assertEquals("Invalid method", HttpMethod.DELETE, exchange.getRequest().getMethod()); + return Mono.empty(); + }; + + filter.setMethodParam("_foo"); + + StepVerifier.create(filter.filter(mockExchange, filterChain)) + .expectComplete() + .verify(); + } + + @Test + public void filterWithoutPost() { + ServerWebExchange mockExchange = createExchange(Optional.of("DELETE")).mutate() + .request(builder -> builder.method(HttpMethod.PUT)) + .build(); + + WebFilterChain filterChain = exchange -> { + assertEquals("Invalid method", HttpMethod.PUT, exchange.getRequest().getMethod()); + return Mono.empty(); + }; + + StepVerifier.create(filter.filter(mockExchange, filterChain)) + .expectComplete() + .verify(); + } + + private ServerWebExchange createExchange(Optional optionalMethod) { + return createExchange("_method", optionalMethod); + } + + private ServerWebExchange createExchange(String methodName, Optional optionalBody) { + MockServerHttpRequest.BodyBuilder builder = MockServerHttpRequest + .post("/hotels") + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE); + + MockServerHttpRequest request = optionalBody + .map(method -> builder.body(methodName + "=" + method)) + .orElse(builder.build()); + + MockServerHttpResponse response = new MockServerHttpResponse(); + + return new DefaultServerWebExchange(request, response); + } + +}