From c276f1c6d4771082c44fc75932364f7192a8b5a7 Mon Sep 17 00:00:00 2001 From: Puppy4C <53632354+Puppy4C@users.noreply.github.com> Date: Mon, 13 May 2024 20:45:57 +0800 Subject: [PATCH] SpringMvcContract support parse params (#1016) --- .../ROOT/pages/spring-cloud-openfeign.adoc | 32 ++++++- .../openfeign/support/SpringMvcContract.java | 58 +++++++++++- .../support/SpringMvcContractTests.java | 90 ++++++++++++++++++- 3 files changed, 177 insertions(+), 3 deletions(-) diff --git a/docs/modules/ROOT/pages/spring-cloud-openfeign.adoc b/docs/modules/ROOT/pages/spring-cloud-openfeign.adoc index b59fbd1c..38e569e7 100644 --- a/docs/modules/ROOT/pages/spring-cloud-openfeign.adoc +++ b/docs/modules/ROOT/pages/spring-cloud-openfeign.adoc @@ -45,7 +45,7 @@ public interface StoreClient { @GetMapping("/stores") Page getStores(Pageable pageable); - @PostMapping(value = "/stores/{storeId}", consumes = "application/json") + @PostMapping(value = "/stores/{storeId}", consumes = "application/json", params = "mode=upsert") Store update(@PathVariable("storeId") Long storeId, Store store); @DeleteMapping("/stores/{storeId:\\d+}") @@ -701,6 +701,36 @@ public interface DemoClient { You can also disable the feature via property `spring.cloud.openfeign.cache.enabled=false`. + +[[spring-requestmapping-support]] +=== Spring @RequestMapping Support + +Spring Cloud OpenFeign provides support for the Spring `@RequestMapping` annotation and its derived annotations (such as `@GetMapping`, `@PostMapping`, and others) support. +The attributes on the `@RequestMapping` annotation (including `value`, `method`, `params`, `headers`, `consumes`, and `produces`) are parsed by `SpringMvcContract` as the content of the request. + + +Consider the following example: + +Define an interface using the `params` attribute. + +[source,java,indent=0] +---- +@FeignClient("demo") +public interface DemoTemplate { + + @PostMapping(value = "/stores/{storeId}", params = "mode=upsert") + Store update(@PathVariable("storeId") Long storeId, Store store); +} +---- + +In the above example, the request url is resolved to `/stores/{storeId}?mode=upsert`. + +The params attribute also supports the use of multiple `key=value` or only one `key`: + + +- When `params = { "key1=v1", "key2=v2" }`, the request url is parsed as `/stores/{storeId}?key1=v1&key2=v2`. +- When `params = "key"`, the request url is parsed as `/stores/{storeId}?key`. + + + [[feign-querymap-support]] === Feign @QueryMap support diff --git a/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/support/SpringMvcContract.java b/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/support/SpringMvcContract.java index f4e44534..8686777f 100644 --- a/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/support/SpringMvcContract.java +++ b/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/support/SpringMvcContract.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2023 the original author or authors. + * Copyright 2013-2024 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. @@ -87,6 +87,7 @@ import static org.springframework.core.annotation.AnnotatedElementUtils.findMerg * @author Darren Foong * @author Ram Anaswara * @author Sam Kruglov + * @author Tang Xiong */ public class SpringMvcContract extends Contract.BaseContract implements ResourceLoaderAware { @@ -244,6 +245,9 @@ public class SpringMvcContract extends Contract.BaseContract implements Resource // headers parseHeaders(data, method, methodMapping); + // params + parseParams(data, method, methodMapping); + data.indexToExpander(new LinkedHashMap<>()); } @@ -354,6 +358,22 @@ public class SpringMvcContract extends Contract.BaseContract implements Resource } } + private void parseParams(MethodMetadata data, Method method, RequestMapping methodMapping) { + String[] params = methodMapping.params(); + if (params == null || params.length == 0) { + return; + } + for (String param : params) { + NameValueResolver nameValueResolver = new NameValueResolver(param); + if (!nameValueResolver.isNegated()) { + data.template().query(resolve(nameValueResolver.getName()), resolve(nameValueResolver.getValue())); + } + else { + throw new IllegalArgumentException("Negated params are not supported: " + param); + } + } + } + private Map, AnnotatedParameterProcessor> toAnnotatedArgumentProcessorMap( List processors) { Map, AnnotatedParameterProcessor> result = new HashMap<>(); @@ -465,4 +485,40 @@ public class SpringMvcContract extends Contract.BaseContract implements Resource } + private static class NameValueResolver { + + private final String name; + + private final String value; + + private final boolean isNegated; + + NameValueResolver(String expression) { + int separator = expression.indexOf('='); + if (separator == -1) { + isNegated = expression.startsWith("!"); + name = (isNegated ? expression.substring(1) : expression); + value = null; + } + else { + isNegated = (separator > 0) && (expression.charAt(separator - 1) == '!'); + name = (isNegated ? expression.substring(0, separator - 1) : expression.substring(0, separator)); + value = expression.substring(separator + 1); + } + } + + public String getName() { + return name; + } + + public String getValue() { + return value; + } + + public boolean isNegated() { + return isNegated; + } + + } + } diff --git a/spring-cloud-openfeign-core/src/test/java/org/springframework/cloud/openfeign/support/SpringMvcContractTests.java b/spring-cloud-openfeign-core/src/test/java/org/springframework/cloud/openfeign/support/SpringMvcContractTests.java index 9f95ba51..1d819760 100644 --- a/spring-cloud-openfeign-core/src/test/java/org/springframework/cloud/openfeign/support/SpringMvcContractTests.java +++ b/spring-cloud-openfeign-core/src/test/java/org/springframework/cloud/openfeign/support/SpringMvcContractTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2023 the original author or authors. + * Copyright 2013-2024 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. @@ -82,6 +82,7 @@ import static org.junit.jupiter.api.Assumptions.assumeTrue; * @author Szymon Linowski * @author Sam Kruglov * @author Bhavya Agrawal + * @author Tang Xiong **/ class SpringMvcContractTests { @@ -462,6 +463,68 @@ class SpringMvcContractTests { assertThat(data.queryMapIndex().intValue()).isEqualTo(0); } + @Test + void testProcessAnnotations_ParseParams_SingleParam() throws Exception { + Method method = TestTemplate_ParseParams.class.getDeclaredMethod("singleParam"); + MethodMetadata data = contract.parseAndValidateMetadata(method.getDeclaringClass(), method); + + assertThat(data.template().url()).isEqualTo("/test?p1=1"); + assertThat(data.template().method()).isEqualTo("GET"); + } + + @Test + void testProcessAnnotations_ParseParams_MultipleParams() throws Exception { + Method method = TestTemplate_ParseParams.class.getDeclaredMethod("multipleParams"); + MethodMetadata data = contract.parseAndValidateMetadata(method.getDeclaringClass(), method); + + assertThat(data.template().url()).isEqualTo("/test?p1=1&p2=2"); + assertThat(data.template().method()).isEqualTo("GET"); + } + + @Test + void testProcessAnnotations_ParseParams_MixParams() throws Exception { + Method method = TestTemplate_ParseParams.class.getDeclaredMethod("mixParams"); + MethodMetadata data = contract.parseAndValidateMetadata(method.getDeclaringClass(), method); + + assertThat(data.template().url()).isEqualTo("/test?p1=1&p2"); + assertThat(data.template().method()).isEqualTo("GET"); + } + + @Test + void testProcessAnnotations_ParseParams_SingleParamWithoutValue() throws Exception { + Method method = TestTemplate_ParseParams.class.getDeclaredMethod("singleParamWithoutValue"); + MethodMetadata data = contract.parseAndValidateMetadata(method.getDeclaringClass(), method); + + assertThat(data.template().url()).isEqualTo("/test?p1"); + assertThat(data.template().method()).isEqualTo("GET"); + } + + @Test + void testProcessAnnotations_ParseParams_MultipleParamsWithoutValue() throws Exception { + Method method = TestTemplate_ParseParams.class.getDeclaredMethod("multipleParamsWithoutValue"); + MethodMetadata data = contract.parseAndValidateMetadata(method.getDeclaringClass(), method); + + assertThat(data.template().url()).isEqualTo("/test?p1&p2"); + assertThat(data.template().method()).isEqualTo("GET"); + } + + @Test + void testProcessAnnotations_ParseParams_NotEqualParams() throws Exception { + assertThatIllegalArgumentException().isThrownBy(() -> { + Method method = TestTemplate_ParseParams.class.getDeclaredMethod("notEqualParams"); + contract.parseAndValidateMetadata(method.getDeclaringClass(), method); + }); + } + + @Test + void testProcessAnnotations_ParseParams_ParamsAndRequestParam() throws Exception { + Method method = TestTemplate_ParseParams.class.getDeclaredMethod("paramsAndRequestParam", String.class); + MethodMetadata data = contract.parseAndValidateMetadata(method.getDeclaringClass(), method); + + assertThat(data.template().url()).isEqualTo("/test?p1=1&p2={p2}"); + assertThat(data.template().method()).isEqualTo("GET"); + } + @Test void testProcessHeaders() throws Exception { Method method = TestTemplate_Headers.class.getDeclaredMethod("getTest", String.class); @@ -750,6 +813,31 @@ class SpringMvcContractTests { } + public interface TestTemplate_ParseParams { + + @GetMapping(value = "test", params = "p1=1") + ResponseEntity singleParam(); + + @GetMapping(value = "test", params = { "p1=1", "p2=2" }) + ResponseEntity multipleParams(); + + @GetMapping(value = "test", params = { "p1" }) + ResponseEntity singleParamWithoutValue(); + + @GetMapping(value = "test", params = { "p1", "p2" }) + ResponseEntity multipleParamsWithoutValue(); + + @GetMapping(value = "test", params = { "p1=1", "p2" }) + ResponseEntity mixParams(); + + @GetMapping(value = "test", params = { "p1!=1" }) + ResponseEntity notEqualParams(); + + @GetMapping(value = "test", params = { "p1=1" }) + ResponseEntity paramsAndRequestParam(@RequestParam("p2") String p2); + + } + public interface TestTemplate_HeaderMap { @GetMapping("/headerMap")