Support custom HTTP method for @HttpExchange

Closes gh-28504
This commit is contained in:
rstoyanchev 2022-05-24 09:23:41 +01:00
parent 48c1746693
commit ff890bc1cc
9 changed files with 52 additions and 146 deletions

View File

@ -1,57 +0,0 @@
/*
* Copyright 2002-2022 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.web.service.annotation;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.core.annotation.AliasFor;
/**
* Shortcut for {@link HttpExchange @HttpExchange} for HTTP HEAD requests.
*
* @author Rossen Stoyanchev
* @since 6.0
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@HttpExchange(method = "HEAD")
public @interface HeadExchange {
/**
* Alias for {@link HttpExchange#value}.
*/
@AliasFor(annotation = HttpExchange.class)
String value() default "";
/**
* Alias for {@link HttpExchange#url()}.
*/
@AliasFor(annotation = HttpExchange.class)
String url() default "";
/**
* Alias for {@link HttpExchange#accept()}.
*/
@AliasFor(annotation = HttpExchange.class)
String[] accept() default {};
}

View File

@ -26,13 +26,16 @@ import org.springframework.core.annotation.AliasFor;
import org.springframework.web.bind.annotation.Mapping; import org.springframework.web.bind.annotation.Mapping;
/** /**
* Annotation that declares an HTTP service method as an HTTP endpoint defined * Annotation to declare a method on an HTTP service interface as an HTTP
* through attributes of the annotation and method argument values. * endpoint. The endpoint details are defined statically through attributes of
* the annotation, as well as through the input method argument values.
* *
* <p>The annotation may only be used at the type level &mdash; for example to * <p>Supported at the type level to express common attributes, to be inherited
* specify a base URL path. At the method level, use one of the HTTP method * by all methods, such as a base URL path.
* specific, shortcut annotations, each of which is <em>meta-annotated</em> with *
* {@code HttpExchange}: * <p>At the method level, it's more common to use one of the below HTTP method
* specific, shortcut annotation, each of which is itself <em>meta-annotated</em>
* with {@code HttpExchange}:
* *
* <ul> * <ul>
* <li>{@link GetExchange} * <li>{@link GetExchange}
@ -40,8 +43,6 @@ import org.springframework.web.bind.annotation.Mapping;
* <li>{@link PutExchange} * <li>{@link PutExchange}
* <li>{@link PatchExchange} * <li>{@link PatchExchange}
* <li>{@link DeleteExchange} * <li>{@link DeleteExchange}
* <li>{@link OptionsExchange}
* <li>{@link HeadExchange}
* </ul> * </ul>
* *
* <p>Supported method arguments: * <p>Supported method arguments:
@ -100,7 +101,7 @@ import org.springframework.web.bind.annotation.Mapping;
* @author Rossen Stoyanchev * @author Rossen Stoyanchev
* @since 6.0 * @since 6.0
*/ */
@Target(ElementType.TYPE) @Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME) @Retention(RetentionPolicy.RUNTIME)
@Documented @Documented
@Mapping @Mapping

View File

@ -1,51 +0,0 @@
/*
* Copyright 2002-2022 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.web.service.annotation;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.core.annotation.AliasFor;
/**
* Shortcut for {@link HttpExchange @HttpExchange} for HTTP OPTIONS requests.
*
* @author Rossen Stoyanchev
* @since 6.0
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@HttpExchange(method = "OPTIONS")
public @interface OptionsExchange {
/**
* Alias for {@link HttpExchange#value}.
*/
@AliasFor(annotation = HttpExchange.class)
String value() default "";
/**
* Alias for {@link HttpExchange#url()}.
*/
@AliasFor(annotation = HttpExchange.class)
String url() default "";
}

View File

@ -22,12 +22,14 @@ import org.apache.commons.logging.LogFactory;
import org.springframework.core.MethodParameter; import org.springframework.core.MethodParameter;
import org.springframework.http.HttpMethod; import org.springframework.http.HttpMethod;
import org.springframework.lang.Nullable; import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
/** /**
* {@link HttpServiceArgumentResolver} that resolves the target * {@link HttpServiceArgumentResolver} that resolves the target
* request's HTTP method from an {@link HttpMethod} argument. * request's HTTP method from an {@link HttpMethod} argument.
* *
* @author Olga Maciaszek-Sharma * @author Olga Maciaszek-Sharma
* @author Rossen Stoyanchev
* @since 6.0 * @since 6.0
*/ */
public class HttpMethodArgumentResolver implements HttpServiceArgumentResolver { public class HttpMethodArgumentResolver implements HttpServiceArgumentResolver {
@ -43,12 +45,11 @@ public class HttpMethodArgumentResolver implements HttpServiceArgumentResolver {
return false; return false;
} }
if (argument != null) { Assert.notNull(argument, "HttpMethod is required");
HttpMethod httpMethod = (HttpMethod) argument; HttpMethod httpMethod = (HttpMethod) argument;
requestValues.setHttpMethod(httpMethod); requestValues.setHttpMethod(httpMethod);
if (logger.isTraceEnabled()) { if (logger.isTraceEnabled()) {
logger.trace("Resolved HTTP method to: " + httpMethod.name()); logger.trace("Resolved HTTP method to: " + httpMethod.name());
}
} }
return true; return true;

View File

@ -56,6 +56,7 @@ public final class HttpRequestValues {
CollectionUtils.toMultiValueMap(Collections.emptyMap()); CollectionUtils.toMultiValueMap(Collections.emptyMap());
@Nullable
private final HttpMethod httpMethod; private final HttpMethod httpMethod;
@Nullable @Nullable
@ -82,7 +83,7 @@ public final class HttpRequestValues {
private final ParameterizedTypeReference<?> bodyElementType; private final ParameterizedTypeReference<?> bodyElementType;
private HttpRequestValues(HttpMethod httpMethod, private HttpRequestValues(@Nullable HttpMethod httpMethod,
@Nullable URI uri, @Nullable String uriTemplate, Map<String, String> uriVariables, @Nullable URI uri, @Nullable String uriTemplate, Map<String, String> uriVariables,
HttpHeaders headers, MultiValueMap<String, String> cookies, Map<String, Object> attributes, HttpHeaders headers, MultiValueMap<String, String> cookies, Map<String, Object> attributes,
@Nullable Object bodyValue, @Nullable Object bodyValue,
@ -106,6 +107,7 @@ public final class HttpRequestValues {
/** /**
* Return the HTTP method to use for the request. * Return the HTTP method to use for the request.
*/ */
@Nullable
public HttpMethod getHttpMethod() { public HttpMethod getHttpMethod() {
return this.httpMethod; return this.httpMethod;
} }
@ -187,8 +189,8 @@ public final class HttpRequestValues {
} }
public static Builder builder(HttpMethod httpMethod) { public static Builder builder() {
return new Builder(httpMethod); return new Builder();
} }
@ -199,6 +201,7 @@ public final class HttpRequestValues {
private static final Function<MultiValueMap<String, String>, byte[]> FORM_DATA_SERIALIZER = new FormDataSerializer(); private static final Function<MultiValueMap<String, String>, byte[]> FORM_DATA_SERIALIZER = new FormDataSerializer();
@Nullable
private HttpMethod httpMethod; private HttpMethod httpMethod;
@Nullable @Nullable
@ -231,16 +234,10 @@ public final class HttpRequestValues {
@Nullable @Nullable
private ParameterizedTypeReference<?> bodyElementType; private ParameterizedTypeReference<?> bodyElementType;
private Builder(HttpMethod httpMethod) {
Assert.notNull(httpMethod, "HttpMethod is required");
this.httpMethod = httpMethod;
}
/** /**
* Set the HTTP method for the request. * Set the HTTP method for the request.
*/ */
public Builder setHttpMethod(HttpMethod httpMethod) { public Builder setHttpMethod(HttpMethod httpMethod) {
Assert.notNull(httpMethod, "HttpMethod is required");
this.httpMethod = httpMethod; this.httpMethod = httpMethod;
return this; return this;
} }

View File

@ -131,7 +131,7 @@ final class HttpServiceMethod {
* and method-level {@link HttpExchange @HttpRequest} annotations. * and method-level {@link HttpExchange @HttpRequest} annotations.
*/ */
private record HttpRequestValuesInitializer( private record HttpRequestValuesInitializer(
HttpMethod httpMethod, @Nullable String url, @Nullable HttpMethod httpMethod, @Nullable String url,
@Nullable MediaType contentType, @Nullable List<MediaType> acceptMediaTypes) { @Nullable MediaType contentType, @Nullable List<MediaType> acceptMediaTypes) {
private HttpRequestValuesInitializer( private HttpRequestValuesInitializer(
@ -145,7 +145,10 @@ final class HttpServiceMethod {
} }
public HttpRequestValues.Builder initializeRequestValuesBuilder() { public HttpRequestValues.Builder initializeRequestValuesBuilder() {
HttpRequestValues.Builder requestValues = HttpRequestValues.builder(this.httpMethod); HttpRequestValues.Builder requestValues = HttpRequestValues.builder();
if (this.httpMethod != null) {
requestValues.setHttpMethod(this.httpMethod);
}
if (this.url != null) { if (this.url != null) {
requestValues.setUriTemplate(this.url); requestValues.setUriTemplate(this.url);
} }
@ -178,7 +181,7 @@ final class HttpServiceMethod {
return new HttpRequestValuesInitializer(httpMethod, url, contentType, acceptableMediaTypes); return new HttpRequestValuesInitializer(httpMethod, url, contentType, acceptableMediaTypes);
} }
@Nullable
private static HttpMethod initHttpMethod(@Nullable HttpExchange typeAnnot, HttpExchange annot) { private static HttpMethod initHttpMethod(@Nullable HttpExchange typeAnnot, HttpExchange annot) {
String value1 = (typeAnnot != null ? typeAnnot.method() : null); String value1 = (typeAnnot != null ? typeAnnot.method() : null);
@ -192,7 +195,7 @@ final class HttpServiceMethod {
return HttpMethod.valueOf(value1); return HttpMethod.valueOf(value1);
} }
throw new IllegalStateException("HttpMethod is required"); return null;
} }
@Nullable @Nullable

View File

@ -20,9 +20,12 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.http.HttpMethod; import org.springframework.http.HttpMethod;
import org.springframework.lang.Nullable;
import org.springframework.web.service.annotation.GetExchange; import org.springframework.web.service.annotation.GetExchange;
import org.springframework.web.service.annotation.HttpExchange;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.assertj.core.api.Assertions.assertThatIllegalStateException; import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
/** /**
@ -47,11 +50,17 @@ public class HttpMethodArgumentResolverTests {
@Test @Test
void requestMethodOverride() { void httpMethodFromArgument() {
this.service.execute(HttpMethod.POST); this.service.execute(HttpMethod.POST);
assertThat(getActualMethod()).isEqualTo(HttpMethod.POST); assertThat(getActualMethod()).isEqualTo(HttpMethod.POST);
} }
@Test
void httpMethodFromAnnotation() {
this.service.executeHttpHead();
assertThat(getActualMethod()).isEqualTo(HttpMethod.HEAD);
}
@Test @Test
void notHttpMethod() { void notHttpMethod() {
assertThatIllegalStateException() assertThatIllegalStateException()
@ -63,11 +72,11 @@ public class HttpMethodArgumentResolverTests {
} }
@Test @Test
void ignoreNull() { void nullHttpMethod() {
this.service.execute(null); assertThatIllegalArgumentException().isThrownBy(() -> this.service.execute(null));
assertThat(getActualMethod()).isEqualTo(HttpMethod.GET);
} }
@Nullable
private HttpMethod getActualMethod() { private HttpMethod getActualMethod() {
return this.client.getRequestValues().getHttpMethod(); return this.client.getRequestValues().getHttpMethod();
} }
@ -75,9 +84,12 @@ public class HttpMethodArgumentResolverTests {
private interface Service { private interface Service {
@GetExchange @HttpExchange
void execute(HttpMethod method); void execute(HttpMethod method);
@HttpExchange(method = "HEAD")
void executeHttpHead();
@GetExchange @GetExchange
void executeNotHttpMethod(String test); void executeNotHttpMethod(String test);

View File

@ -38,7 +38,7 @@ public class HttpRequestValuesTests {
@Test @Test
void defaultUri() { void defaultUri() {
HttpRequestValues requestValues = HttpRequestValues.builder(HttpMethod.GET).build(); HttpRequestValues requestValues = HttpRequestValues.builder().setHttpMethod(HttpMethod.GET).build();
assertThat(requestValues.getUri()).isNull(); assertThat(requestValues.getUri()).isNull();
assertThat(requestValues.getUriTemplate()).isEqualTo(""); assertThat(requestValues.getUriTemplate()).isEqualTo("");
@ -48,7 +48,7 @@ public class HttpRequestValuesTests {
@ValueSource(strings = {"POST", "PUT", "PATCH"}) @ValueSource(strings = {"POST", "PUT", "PATCH"})
void requestParamAsFormData(String httpMethod) { void requestParamAsFormData(String httpMethod) {
HttpRequestValues requestValues = HttpRequestValues.builder(HttpMethod.valueOf(httpMethod)) HttpRequestValues requestValues = HttpRequestValues.builder().setHttpMethod(HttpMethod.valueOf(httpMethod))
.setContentType(MediaType.APPLICATION_FORM_URLENCODED) .setContentType(MediaType.APPLICATION_FORM_URLENCODED)
.addRequestParameter("param1", "1st value") .addRequestParameter("param1", "1st value")
.addRequestParameter("param2", "2nd value A", "2nd value B") .addRequestParameter("param2", "2nd value A", "2nd value B")
@ -62,7 +62,7 @@ public class HttpRequestValuesTests {
@Test @Test
void requestParamAsQueryParamsInUriTemplate() { void requestParamAsQueryParamsInUriTemplate() {
HttpRequestValues requestValues = HttpRequestValues.builder(HttpMethod.POST) HttpRequestValues requestValues = HttpRequestValues.builder().setHttpMethod(HttpMethod.POST)
.setUriTemplate("/path") .setUriTemplate("/path")
.addRequestParameter("param1", "1st value") .addRequestParameter("param1", "1st value")
.addRequestParameter("param2", "2nd value A", "2nd value B") .addRequestParameter("param2", "2nd value A", "2nd value B")
@ -96,7 +96,7 @@ public class HttpRequestValuesTests {
@Test @Test
void requestParamAsQueryParamsInUri() { void requestParamAsQueryParamsInUri() {
HttpRequestValues requestValues = HttpRequestValues.builder(HttpMethod.POST) HttpRequestValues requestValues = HttpRequestValues.builder().setHttpMethod(HttpMethod.POST)
.setUri(URI.create("/path")) .setUri(URI.create("/path"))
.addRequestParameter("param1", "1st value") .addRequestParameter("param1", "1st value")
.addRequestParameter("param2", "2nd value A", "2nd value B") .addRequestParameter("param2", "2nd value A", "2nd value B")

View File

@ -86,7 +86,7 @@ public class WebClientAdapter implements HttpClientAdapter {
private WebClient.RequestBodySpec toBodySpec(HttpRequestValues requestValues) { private WebClient.RequestBodySpec toBodySpec(HttpRequestValues requestValues) {
HttpMethod httpMethod = requestValues.getHttpMethod(); HttpMethod httpMethod = requestValues.getHttpMethod();
Assert.notNull(httpMethod, "No HttpMethod"); Assert.notNull(httpMethod, "HttpMethod is required");
WebClient.RequestBodyUriSpec uriSpec = this.webClient.method(httpMethod); WebClient.RequestBodyUriSpec uriSpec = this.webClient.method(httpMethod);