diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5ee95452e1..4ca1229f19 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,11 +25,13 @@ jobs: matrix: java: [ 17 ] spring-boot-version: [ 3.4.3 ] + spring-cloud-version: [ 2024.0.1 ] spring-boot-display-version: [ 3.4.x ] experimental: [ false ] include: - java: 17 spring-boot-version: 3.3.9 + spring-cloud-version: 2023.0.5 spring-boot-display-version: 3.3.x experimental: false env: @@ -126,7 +128,7 @@ jobs: run: ./mvnw install -q -B -DskipTests - name: Integration tests using spring boot version ${{ matrix.spring-boot-version }} id: integration_tests - run: PRODUCT_SPRING_BOOT_VERSION=${{ matrix.spring-boot-version }} ./mvnw -B -Pintegration-tests verify + run: PRODUCT_SPRING_BOOT_VERSION=${{ matrix.spring-boot-version }} PRODUCT_SPRING_CLOUD_VERSION=${{ matrix.spring-cloud-version }} ./mvnw -B -Pintegration-tests verify env: DOCKER_HOST: ${{steps.setup_docker.outputs.sock}} - name: Upload test report for sdk diff --git a/dapr-spring/dapr-openfeign-client/pom.xml b/dapr-spring/dapr-openfeign-client/pom.xml new file mode 100644 index 0000000000..0bfad1bca1 --- /dev/null +++ b/dapr-spring/dapr-openfeign-client/pom.xml @@ -0,0 +1,36 @@ + + + 4.0.0 + + io.dapr.spring + dapr-spring-parent + 0.15.0-SNAPSHOT + + + dapr-openfeign-client + + + 17 + 17 + UTF-8 + ./spotbugs-exclude.xml + + + + + io.github.openfeign + feign-core + 13.2.1 + compile + + + io.github.openfeign + feign-jaxrs + 13.5 + test + + + + \ No newline at end of file diff --git a/dapr-spring/dapr-openfeign-client/spotbugs-exclude.xml b/dapr-spring/dapr-openfeign-client/spotbugs-exclude.xml new file mode 100644 index 0000000000..f47dce3e23 --- /dev/null +++ b/dapr-spring/dapr-openfeign-client/spotbugs-exclude.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/dapr-spring/dapr-openfeign-client/src/main/java/io/dapr/feign/DaprInvokeFeignClient.java b/dapr-spring/dapr-openfeign-client/src/main/java/io/dapr/feign/DaprInvokeFeignClient.java new file mode 100644 index 0000000000..b7c8e9e909 --- /dev/null +++ b/dapr-spring/dapr-openfeign-client/src/main/java/io/dapr/feign/DaprInvokeFeignClient.java @@ -0,0 +1,399 @@ +/* + * Copyright 2025 The Dapr 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 io.dapr.feign; + +import feign.Client; +import feign.Request; +import feign.Response; +import feign.Util; +import io.dapr.client.DaprClient; +import io.dapr.client.DaprClientBuilder; +import io.dapr.client.domain.HttpExtension; +import io.dapr.client.domain.InvokeBindingRequest; +import io.dapr.client.domain.InvokeMethodRequest; +import io.dapr.utils.TypeRef; +import reactor.core.publisher.Mono; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.charset.Charset; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import static java.nio.charset.StandardCharsets.UTF_8; + +/** + * This module directs Feign's requests to Dapr, which is a microservice framework. Ex. + * + *

Currently, Dapr supports two ways to invoke operations, which is invokeBinding (Output Binding) and + * invokeMethod, so this Client supports two schemas: http://binding.xxx or http://method.xxx
+ * You don't have to mind why there is a http schema at start, it's just a trick to hack Spring Boot Openfeign. + * + *

For invokeMethod, there are two types of information in the url, which is very similar to an HTTP URL, except that + * the host in the HTTP URL is converted to appId, and the path (excluding "/") is converted to methodName.
+ * For example, if you have a method which appid is "myApp", and methodName is "getAll", then the url of this request is + * "http://method.myApp/getAll".
+ * You can also set HTTP Headers if you like, the Client will handle them.
+ * Currently only HTTP invokes are supported, but I think we will support grpc invokes in the future, may be the url + * will be "http://method_grpc.myApp/getAll" or something. + * + *

For invokeBinding, there are also two types of information in the url, the host is the bindingName, and the path + * is the operation.
+ * Note: different bindings support different operations, so you have to check + * the documentation of + * Dapr for that.
+ * For example, if you have a binding which bindingName is "myBinding", and the operation supported is "create", and + * then the url of this request is "http://binding.myBinding/create"
+ * You can put some metadata into the Header of your Feign Request, the Client will handle them. + * + *

As for response, the result code is always 200 OK, and if client have met any error, it will throw an IOException + * for that.
+ * Currently, we have no method to gain metadata from server as Dapr Client doesn't have methods to do that, so headers + * will be blank. If Accept header has set in request, a fake Content-Type header will be created in response, and it + * will be the first value of Accept header. + * + *

+ * MyAppData response = Feign.builder().client(new DaprFeignClient()).target(MyAppData.class,
+ * "http://binding.myBinding/create");
+ * 
+ */ +public class DaprInvokeFeignClient implements Client { + + private static final Map httpExtensionMap = generateHttpExtensionMap(); + + private static final String DOT = "\\."; + + private final int retry; + private final int timeout; + + private final DaprClient daprClient; + + /** + * Default Client creation with no arguments. + * + *

+ * retry 5 tries with 2000ms waiting and default DaprClient. + *

+ */ + public DaprInvokeFeignClient() { + daprClient = new DaprClientBuilder().build(); + retry = 2000; + timeout = 3; + } + + /** + * Client creation with DaprClient Specified. + * + *

+ * retry 5 tries with 2000ms waiting. + *

+ * + * @param daprClient client sepcified + */ + public DaprInvokeFeignClient(DaprClient daprClient) { + this.daprClient = daprClient; + retry = 2000; + timeout = 5; + } + + /** + * Client creation with DaprClient, wait time, retry time Specified. + * + * @param daprClient client sepcified + * @param timeout wait time (ms) + * @param retry retry times + */ + public DaprInvokeFeignClient(DaprClient daprClient, int timeout, int retry) { + this.daprClient = daprClient; + this.timeout = timeout; + this.retry = retry; + } + + private static Map generateHttpExtensionMap() { + Map tempHttpExtensionMap = new HashMap<>(); + + tempHttpExtensionMap.put("none", + HttpExtension.NONE); + tempHttpExtensionMap.put("put", + HttpExtension.PUT); + tempHttpExtensionMap.put("post", + HttpExtension.POST); + tempHttpExtensionMap.put("delete", + HttpExtension.DELETE); + tempHttpExtensionMap.put("head", + HttpExtension.HEAD); + tempHttpExtensionMap.put("connect", + HttpExtension.CONNECT); + tempHttpExtensionMap.put("options", + HttpExtension.OPTIONS); + tempHttpExtensionMap.put("trace", + HttpExtension.TRACE); + tempHttpExtensionMap.put("get", + HttpExtension.GET); + + return tempHttpExtensionMap; + } + + @Override + public Response execute(Request request, Request.Options options) throws IOException { + URI uri; + try { + uri = new URI(request.url()); + } catch (URISyntaxException e) { + throw new IOException("URL '" + request.url() + "' couldn't be parsed into a URI", e); + } + + String schemaAndHost = uri.getHost(); + + String[] splitSchemaAndHost = schemaAndHost.split(DOT); + String schema; + + if (splitSchemaAndHost.length >= 2) { + schema = splitSchemaAndHost[0]; + } else { + throw new IOException("Host '" + schemaAndHost + "' is not supported"); + } + + Mono result = null; + + switch (schema) { + case "method": + result = getResultFromInvokeHTTPMethodRequest(uri, request); + break; + case "binding": + result = getResultFromInvokeBindingRequest(uri, request); + break; + default: + throw new IOException("Schema '" + schema + "' is not supported"); + } + + Map> headerMap = new HashMap<>(); + + if (request.headers().containsKey("Accept")) { + headerMap.put("Content-Type", List.of(request.headers().get("Accept") + .stream().findFirst() + .orElseThrow(() -> new IOException("Accept header can not be null")))); + } + + return Response.builder() + .status(200) + .reason("OK") + .request(request) + .headers(headerMap) + .body(toResponseBody(result, options)) + .build(); + } + + @SuppressWarnings("checkstyle:AbbreviationAsWordInName") + private Mono getResultFromInvokeHTTPMethodRequest(URI uri, Request request) throws IOException { + String[] splitSchemaAndHost = uri.getHost().split(DOT); + + List hostList = new ArrayList<>(List.of(splitSchemaAndHost)); + hostList.remove(0); + + InvokeMethodRequest invokeMethodRequest = null; + try { + invokeMethodRequest = toInvokeMethodHTTPRequest(new URI("method", + uri.getUserInfo(), + String.join(".", hostList), + uri.getPort(), + uri.getPath(), + uri.getQuery(), + uri.getFragment()), request); + } catch (URISyntaxException e) { + throw new IOException("URL '" + request.url() + "' couldn't be parsed into a URI", e); + } + + return daprClient.invokeMethod(invokeMethodRequest, TypeRef.BYTE_ARRAY); + + } + + private Mono getResultFromInvokeBindingRequest(URI uri, Request request) throws IOException { + String[] splitSchemaAndHost = uri.getHost().split(DOT); + + List hostList = new ArrayList<>(List.of(splitSchemaAndHost)); + hostList.remove(0); + + InvokeBindingRequest invokeBindingRequest = null; + try { + invokeBindingRequest = toInvokeBindingRequest(new URI("binding", + uri.getUserInfo(), + String.join(".", hostList), + uri.getPort(), + uri.getPath(), + uri.getQuery(), + uri.getFragment()), request); + } catch (URISyntaxException e) { + throw new IOException("URL '" + request.url() + "' couldn't be parsed into a URI", e); + } + + return daprClient.invokeBinding(invokeBindingRequest, TypeRef.BYTE_ARRAY); + } + + @SuppressWarnings("checkstyle:AbbreviationAsWordInName") + private InvokeMethodRequest toInvokeMethodHTTPRequest(URI uri, Request request) throws IOException { + + String path = uri.getPath(); + if (path.startsWith("/")) { + path = path.substring(1); + } + + if (path.endsWith("/")) { + path = path.substring(0, path.length() - 2); + } + + InvokeMethodRequest invokeMethodRequest = new InvokeMethodRequest(uri.getHost(), path); + invokeMethodRequest.setMetadata(toHeader(request.headers())); + + if (request.body() != null) { + invokeMethodRequest.setBody(request.body()); + } + + invokeMethodRequest.setContentType(getContentType(request)); + invokeMethodRequest.setHttpExtension(toHttpExtension(request.httpMethod().name().toLowerCase())); + + return invokeMethodRequest; + } + + private HttpExtension toHttpExtension(String method) throws IOException { + if (!httpExtensionMap.containsKey(method)) { + throw new IOException("Method '" + method + "' is not supported"); + } + return httpExtensionMap.get(method); + } + + private String getContentType(Request request) { + String contentType = null; + for (Map.Entry> entry : request.headers().entrySet()) { + if (entry.getKey().equalsIgnoreCase("Content-Type")) { + Collection values = entry.getValue(); + if (values != null && !values.isEmpty()) { + contentType = values.iterator().next(); + break; + } + } + } + return contentType; + } + + private InvokeBindingRequest toInvokeBindingRequest(URI uri, Request request) throws IOException { + String path = uri.getPath(); + if (path.startsWith("/")) { + path = path.substring(1); + } + + if (path.endsWith("/")) { + path = path.substring(0, path.length() - 2); + } + + if (path.split("/").length > 1) { + throw new IOException("Binding Operation '" + path + "' is not reconigzed"); + } + + InvokeBindingRequest invokeBindingRequest = new InvokeBindingRequest(uri.getHost(), path); + invokeBindingRequest.setMetadata(toHeader(request.headers())); + + if (request.body() != null) { + invokeBindingRequest.setData(request.body()); + } + + return invokeBindingRequest; + } + + private Map toHeader(Map> header) { + Map headerMap = new HashMap<>(); + + // request headers + for (Map.Entry> headerEntry : header.entrySet()) { + String headerName = headerEntry.getKey(); + + if (headerName.equalsIgnoreCase(Util.CONTENT_LENGTH)) { + continue; + } + + for (String headerValue : headerEntry.getValue()) { + headerMap.put(headerName, headerValue); + } + } + + return headerMap; + } + + private Response.Body toResponseBody(Mono response, Request.Options options) throws IOException { + byte[] result = new byte[0]; + + for (int count = 0; count < retry; count++) { + try { + result = response.block(Duration.of(timeout, + TimeUnit.MILLISECONDS.toChronoUnit())); + + if (result == null) { + result = new byte[0]; + } + + break; + } catch (RuntimeException e) { + if (retry == count + 1) { + throw new IOException("Can not get Response", e); + } + } + } + + + byte[] finalResult = result; + return new Response.Body() { + @Override + public Integer length() { + return finalResult.length; + } + + @Override + public boolean isRepeatable() { + return true; + } + + @Override + public InputStream asInputStream() throws IOException { + return new ByteArrayInputStream(finalResult); + } + + @SuppressWarnings("deprecation") + @Override + public Reader asReader() throws IOException { + return new InputStreamReader(asInputStream(), UTF_8); + } + + @Override + public Reader asReader(Charset charset) throws IOException { + return new InputStreamReader(asInputStream(), charset); + } + + @Override + public void close() throws IOException { + + } + }; + } + +} diff --git a/dapr-spring/dapr-openfeign-client/src/test/java/io/dapr/feign/DaprFeignClientBindingTest.java b/dapr-spring/dapr-openfeign-client/src/test/java/io/dapr/feign/DaprFeignClientBindingTest.java new file mode 100644 index 0000000000..5348ff12ed --- /dev/null +++ b/dapr-spring/dapr-openfeign-client/src/test/java/io/dapr/feign/DaprFeignClientBindingTest.java @@ -0,0 +1,66 @@ +/* + * Copyright 2025 The Dapr 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 io.dapr.feign; + + +import feign.Body; +import feign.Feign; +import feign.Headers; +import feign.RequestLine; +import feign.Response; +import io.dapr.client.DaprClient; +import io.dapr.client.domain.InvokeBindingRequest; +import io.dapr.utils.TypeRef; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import reactor.core.publisher.Mono; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@ExtendWith(MockitoExtension.class) +public class DaprFeignClientBindingTest { + + @Mock + DaprClient daprClient; + + @Test + void DaprFeignClient_testMockBindingInvoke() { + DaprFeignClientTestInterface repository = + newBuilder().target(DaprFeignClientTestInterface.class, "http://binding.myBinding"); + + assertEquals(200, repository.getWithContentType().status()); + assertEquals(0, repository.post().body().length()); + } + + public Feign.Builder newBuilder() { + Mockito.when(daprClient.invokeBinding(Mockito.any(InvokeBindingRequest.class), Mockito.eq(TypeRef.BYTE_ARRAY))) + .thenReturn(Mono.just(new byte[0])); + + return Feign.builder().client(new DaprInvokeFeignClient(daprClient)); + } + + public interface DaprFeignClientTestInterface { + + @RequestLine("GET /create") + @Headers({"Accept: text/plain", "Content-Type: text/plain"}) + Response getWithContentType(); + + @RequestLine("POST /get") + @Body("test") + Response post(); + } +} diff --git a/dapr-spring/dapr-openfeign-client/src/test/java/io/dapr/feign/DaprFeignClientMethodTest.java b/dapr-spring/dapr-openfeign-client/src/test/java/io/dapr/feign/DaprFeignClientMethodTest.java new file mode 100644 index 0000000000..ac7d4e38ed --- /dev/null +++ b/dapr-spring/dapr-openfeign-client/src/test/java/io/dapr/feign/DaprFeignClientMethodTest.java @@ -0,0 +1,68 @@ +/* + * Copyright 2025 The Dapr 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 io.dapr.feign; + + +import feign.Body; +import feign.Feign; +import feign.Headers; +import feign.RequestLine; +import feign.Response; +import io.dapr.client.DaprClient; +import io.dapr.client.domain.InvokeMethodRequest; +import io.dapr.utils.TypeRef; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import reactor.core.publisher.Mono; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@ExtendWith(MockitoExtension.class) +public class DaprFeignClientMethodTest { + + @Mock + DaprClient daprClient; + + @Test + void DaprFeignClient_testMockMethodInvoke() { + DaprFeignClientTestInterface repository = + newBuilder().target(DaprFeignClientTestInterface.class, "http://method.myApp/"); + + assertEquals(12, repository.getWithContentType().body().length()); + assertEquals(200, repository.post().status()); + } + + public Feign.Builder newBuilder() { + Mockito.when(daprClient.invokeMethod(Mockito.any(InvokeMethodRequest.class), Mockito.eq(TypeRef.BYTE_ARRAY))) + .thenReturn(Mono.just("hello world!".getBytes(StandardCharsets.UTF_8))); + + return Feign.builder().client(new DaprInvokeFeignClient(daprClient)); + } + + public interface DaprFeignClientTestInterface { + + @RequestLine("GET /getAll") + @Headers({"Accept: text/plain", "Content-Type: text/plain"}) + Response getWithContentType(); + + @RequestLine("POST /abc/") + @Body("test") + Response post(); + } +} diff --git a/dapr-spring/dapr-spring-boot-starters/dapr-spring-boot-starter/pom.xml b/dapr-spring/dapr-spring-boot-starters/dapr-spring-boot-starter/pom.xml index 623040b378..87ce7edadf 100644 --- a/dapr-spring/dapr-spring-boot-starters/dapr-spring-boot-starter/pom.xml +++ b/dapr-spring/dapr-spring-boot-starters/dapr-spring-boot-starter/pom.xml @@ -45,6 +45,12 @@ dapr-spring-workflows ${project.parent.version} + + io.dapr.spring + dapr-spring-openfeign + ${project.parent.version} + true + diff --git a/dapr-spring/dapr-spring-openfeign/pom.xml b/dapr-spring/dapr-spring-openfeign/pom.xml new file mode 100644 index 0000000000..64e85cfa1f --- /dev/null +++ b/dapr-spring/dapr-spring-openfeign/pom.xml @@ -0,0 +1,56 @@ + + + 4.0.0 + + io.dapr.spring + dapr-spring-parent + 0.15.0-SNAPSHOT + + + dapr-spring-openfeign + dapr-spring-openfeign + Dapr Spring OpenFeign + jar + + + 17 + 17 + UTF-8 + + + + + org.springframework.cloud + spring-cloud-openfeign-core + 4.1.4 + compile + + + io.dapr.spring + dapr-openfeign-client + ${project.parent.version} + + + org.springframework.boot + spring-boot-starter-web + test + + + org.springframework.cloud + spring-cloud-starter-openfeign + 4.1.4 + test + + + + + + + org.sonatype.plugins + nexus-staging-maven-plugin + + + + \ No newline at end of file diff --git a/dapr-spring/dapr-spring-openfeign/src/main/java/io/dapr/spring/openfeign/annotation/UseDaprClient.java b/dapr-spring/dapr-spring-openfeign/src/main/java/io/dapr/spring/openfeign/annotation/UseDaprClient.java new file mode 100644 index 0000000000..2827073d6d --- /dev/null +++ b/dapr-spring/dapr-spring-openfeign/src/main/java/io/dapr/spring/openfeign/annotation/UseDaprClient.java @@ -0,0 +1,29 @@ +/* + * Copyright 2025 The Dapr 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 io.dapr.spring.openfeign.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; + +/** + * Let OpenFeign create Client Proxy using Dapr Client to handle requests. + */ +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface UseDaprClient { +} diff --git a/dapr-spring/dapr-spring-openfeign/src/main/java/io/dapr/spring/openfeign/autoconfigure/DaprFeignClientAutoConfiguration.java b/dapr-spring/dapr-spring-openfeign/src/main/java/io/dapr/spring/openfeign/autoconfigure/DaprFeignClientAutoConfiguration.java new file mode 100644 index 0000000000..f6d9be8161 --- /dev/null +++ b/dapr-spring/dapr-spring-openfeign/src/main/java/io/dapr/spring/openfeign/autoconfigure/DaprFeignClientAutoConfiguration.java @@ -0,0 +1,47 @@ +/* + * Copyright 2025 The Dapr 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 io.dapr.spring.openfeign.autoconfigure; + +import io.dapr.client.DaprClient; +import io.dapr.feign.DaprInvokeFeignClient; +import io.dapr.spring.openfeign.targeter.DaprClientTargeterBeanPostProcessor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.cloud.openfeign.FeignAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Conditional; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; + +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(DaprFeignClientProperties.class) +@Conditional(FeignClientAnnoationEnabledCondition.class) +@ConditionalOnClass(FeignAutoConfiguration.class) +@Import(DaprClientTargeterBeanPostProcessor.class) +public class DaprFeignClientAutoConfiguration { + + @SuppressWarnings("checkstyle:MissingJavadocMethod") + @Bean + @ConditionalOnMissingBean + public DaprInvokeFeignClient daprInvokeFeignClient(DaprClient daprClient, + DaprFeignClientProperties daprFeignClientProperties) { + return new DaprInvokeFeignClient( + daprClient, + daprFeignClientProperties.getTimeout(), + daprFeignClientProperties.getRetries() + ); + } +} diff --git a/dapr-spring/dapr-spring-openfeign/src/main/java/io/dapr/spring/openfeign/autoconfigure/DaprFeignClientProperties.java b/dapr-spring/dapr-spring-openfeign/src/main/java/io/dapr/spring/openfeign/autoconfigure/DaprFeignClientProperties.java new file mode 100644 index 0000000000..ebad216268 --- /dev/null +++ b/dapr-spring/dapr-spring-openfeign/src/main/java/io/dapr/spring/openfeign/autoconfigure/DaprFeignClientProperties.java @@ -0,0 +1,42 @@ +/* + * Copyright 2025 The Dapr 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 io.dapr.spring.openfeign.autoconfigure; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(DaprFeignClientProperties.PROPERTY_PREFIX) +public class DaprFeignClientProperties { + + public static final String PROPERTY_PREFIX = "dapr.feign"; + + private Integer timeout = 2000; + private Integer retries = 3; + + public Integer getTimeout() { + return timeout; + } + + public void setTimeout(Integer timeout) { + this.timeout = timeout; + } + + public Integer getRetries() { + return retries; + } + + public void setRetries(Integer retries) { + this.retries = retries; + } +} diff --git a/dapr-spring/dapr-spring-openfeign/src/main/java/io/dapr/spring/openfeign/autoconfigure/FeignClientAnnoationEnabledCondition.java b/dapr-spring/dapr-spring-openfeign/src/main/java/io/dapr/spring/openfeign/autoconfigure/FeignClientAnnoationEnabledCondition.java new file mode 100644 index 0000000000..8603dbf160 --- /dev/null +++ b/dapr-spring/dapr-spring-openfeign/src/main/java/io/dapr/spring/openfeign/autoconfigure/FeignClientAnnoationEnabledCondition.java @@ -0,0 +1,24 @@ +package io.dapr.spring.openfeign.autoconfigure; + +import org.jetbrains.annotations.NotNull; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.context.annotation.Condition; +import org.springframework.context.annotation.ConditionContext; +import org.springframework.core.type.AnnotatedTypeMetadata; + +import java.util.Objects; + +public class FeignClientAnnoationEnabledCondition implements Condition { + @Override + @SuppressWarnings("null") + public boolean matches(@NotNull ConditionContext context, @NotNull AnnotatedTypeMetadata metadata) { + try { + ConfigurableListableBeanFactory factory = Objects.requireNonNull(context.getBeanFactory()); + String[] beanNames = factory.getBeanNamesForAnnotation(EnableFeignClients.class); + return beanNames != null && beanNames.length > 0; + } catch (Exception e) { + return false; + } + } +} diff --git a/dapr-spring/dapr-spring-openfeign/src/main/java/io/dapr/spring/openfeign/targeter/DaprClientTargeter.java b/dapr-spring/dapr-spring-openfeign/src/main/java/io/dapr/spring/openfeign/targeter/DaprClientTargeter.java new file mode 100644 index 0000000000..520c5ece61 --- /dev/null +++ b/dapr-spring/dapr-spring-openfeign/src/main/java/io/dapr/spring/openfeign/targeter/DaprClientTargeter.java @@ -0,0 +1,74 @@ +/* + * Copyright 2025 The Dapr 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 io.dapr.spring.openfeign.targeter; + +import feign.Client; +import feign.Feign; +import feign.Target; +import io.dapr.feign.DaprInvokeFeignClient; +import io.dapr.spring.openfeign.annotation.UseDaprClient; +import org.springframework.cloud.openfeign.FeignClientFactory; +import org.springframework.cloud.openfeign.FeignClientFactoryBean; +import org.springframework.cloud.openfeign.Targeter; + +import java.lang.reflect.Field; + +public class DaprClientTargeter implements Targeter { + + private final DaprInvokeFeignClient daprInvokeFeignClient; + private final Targeter targeter; + + public DaprClientTargeter(DaprInvokeFeignClient daprInvokeFeignClient, Targeter targeter) { + this.daprInvokeFeignClient = daprInvokeFeignClient; + this.targeter = targeter; + } + + @Override + public T target(FeignClientFactoryBean factory, Feign.Builder feign, FeignClientFactory context, + Target.HardCodedTarget target) { + Class classOfT = target.type(); + UseDaprClient useDaprClient = classOfT.getAnnotation(UseDaprClient.class); + + if (useDaprClient == null) { + return targeter.target( + factory, + feign, + context, + target + ); + } + + Class builderClass = Feign.Builder.class; + + Client defaultClient = null; + + try { + Field clientField = builderClass.getDeclaredField("client"); + clientField.setAccessible(true); + + defaultClient = (Client) clientField.get(feign); + + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException(e); + } + + feign.client(daprInvokeFeignClient); + + T targetInstance = feign.target(target); + + feign.client(defaultClient); + + return targetInstance; + } +} diff --git a/dapr-spring/dapr-spring-openfeign/src/main/java/io/dapr/spring/openfeign/targeter/DaprClientTargeterBeanPostProcessor.java b/dapr-spring/dapr-spring-openfeign/src/main/java/io/dapr/spring/openfeign/targeter/DaprClientTargeterBeanPostProcessor.java new file mode 100644 index 0000000000..c2d334f853 --- /dev/null +++ b/dapr-spring/dapr-spring-openfeign/src/main/java/io/dapr/spring/openfeign/targeter/DaprClientTargeterBeanPostProcessor.java @@ -0,0 +1,41 @@ +/* + * Copyright 2025 The Dapr 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 io.dapr.spring.openfeign.targeter; + +import io.dapr.feign.DaprInvokeFeignClient; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.cloud.openfeign.Targeter; +import org.springframework.stereotype.Component; + +@Component +@ConditionalOnBean(Targeter.class) +public class DaprClientTargeterBeanPostProcessor implements BeanPostProcessor { + + private final DaprInvokeFeignClient daprInvokeFeignClient; + + public DaprClientTargeterBeanPostProcessor(DaprInvokeFeignClient daprInvokeFeignClient) { + this.daprInvokeFeignClient = daprInvokeFeignClient; + } + + @Override + public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { + if (bean instanceof Targeter) { + return new DaprClientTargeter(daprInvokeFeignClient, (Targeter) bean); + } + return bean; + } +} diff --git a/dapr-spring/dapr-spring-openfeign/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/dapr-spring/dapr-spring-openfeign/src/main/resources/META-INF/additional-spring-configuration-metadata.json new file mode 100644 index 0000000000..7a71528312 --- /dev/null +++ b/dapr-spring/dapr-spring-openfeign/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -0,0 +1,18 @@ +{ + "properties": [ + { + "name": "dapr.feign.timeout", + "type": "java.lang.Integer", + "defaultValue": 2000, + "sourceType": "io.dapr.spring.openfeign.autoconfigure.DaprFeignClientProperties", + "description": "timeout for dapr invoke." + }, + { + "name": "dapr.feign.retries", + "type": "java.lang.Integer", + "defaultValue": 3, + "sourceType": "io.dapr.spring.openfeign.autoconfigure.DaprFeignClientProperties", + "description": "retries for invoke fails." + } + ] +} \ No newline at end of file diff --git a/dapr-spring/dapr-spring-openfeign/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/dapr-spring/dapr-spring-openfeign/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000000..995fe8a402 --- /dev/null +++ b/dapr-spring/dapr-spring-openfeign/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +io.dapr.spring.openfeign.autoconfigure.DaprFeignClientAutoConfiguration \ No newline at end of file diff --git a/dapr-spring/dapr-spring-openfeign/src/test/java/io/dapr/spring/openfeign/DaprOpenFeignClientTestApplication.java b/dapr-spring/dapr-spring-openfeign/src/test/java/io/dapr/spring/openfeign/DaprOpenFeignClientTestApplication.java new file mode 100644 index 0000000000..a5e5aa9e6c --- /dev/null +++ b/dapr-spring/dapr-spring-openfeign/src/test/java/io/dapr/spring/openfeign/DaprOpenFeignClientTestApplication.java @@ -0,0 +1,27 @@ +/* + * Copyright 2025 The Dapr 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 io.dapr.spring.openfeign; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.openfeign.EnableFeignClients; + +@EnableFeignClients +@SpringBootApplication +public class DaprOpenFeignClientTestApplication { + public static void main(String[] args) { + SpringApplication.run(DaprOpenFeignClientTestApplication.class, args); + } +} diff --git a/dapr-spring/dapr-spring-openfeign/src/test/java/io/dapr/spring/openfeign/DaprOpenFeignClientTests.java b/dapr-spring/dapr-spring-openfeign/src/test/java/io/dapr/spring/openfeign/DaprOpenFeignClientTests.java new file mode 100644 index 0000000000..4e9edc2226 --- /dev/null +++ b/dapr-spring/dapr-spring-openfeign/src/test/java/io/dapr/spring/openfeign/DaprOpenFeignClientTests.java @@ -0,0 +1,62 @@ +/* + * Copyright 2025 The Dapr 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 io.dapr.spring.openfeign; + +import io.dapr.client.DaprClient; +import io.dapr.client.domain.InvokeBindingRequest; +import io.dapr.client.domain.InvokeMethodRequest; +import io.dapr.spring.openfeign.client.DaprInvokeBindingClient; +import io.dapr.spring.openfeign.client.DaprInvokeMethodClient; +import io.dapr.utils.TypeRef; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import reactor.core.publisher.Mono; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@SpringBootTest +public class DaprOpenFeignClientTests { + + @MockBean + DaprClient daprClient; + + @Autowired + DaprInvokeBindingClient invokeBindingClient; + + @Autowired + DaprInvokeMethodClient invokeMethodClient; + + @Test + public void daprInvokeMethodTest() { + Mockito.when(daprClient.invokeMethod(Mockito.any(InvokeMethodRequest.class), Mockito.eq(TypeRef.BYTE_ARRAY))) + .thenReturn(Mono.just("Hello World!".getBytes(StandardCharsets.UTF_8))); + + assertEquals("Hello World!", invokeMethodClient.getQuery()); + } + + @Test + public void daprInvokeBindingTest() { + Mockito.when(daprClient.invokeBinding(Mockito.any(InvokeBindingRequest.class), Mockito.eq(TypeRef.BYTE_ARRAY))) + .thenReturn(Mono.just("Hello World!".getBytes(StandardCharsets.UTF_8))); + + assertEquals("Hello World!", invokeBindingClient.getQuery()); + } + +} diff --git a/dapr-spring/dapr-spring-openfeign/src/test/java/io/dapr/spring/openfeign/client/DaprInvokeBindingClient.java b/dapr-spring/dapr-spring-openfeign/src/test/java/io/dapr/spring/openfeign/client/DaprInvokeBindingClient.java new file mode 100644 index 0000000000..94665e3a15 --- /dev/null +++ b/dapr-spring/dapr-spring-openfeign/src/test/java/io/dapr/spring/openfeign/client/DaprInvokeBindingClient.java @@ -0,0 +1,27 @@ +/* + * Copyright 2025 The Dapr 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 io.dapr.spring.openfeign.client; + +import io.dapr.spring.openfeign.annotation.UseDaprClient; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; + + +@FeignClient(name = "invoke-binding", url = "http://binding.democlient/") +@UseDaprClient +public interface DaprInvokeBindingClient { + @GetMapping(value = "/create", produces = "text/plain;charset=utf-8") + String getQuery(); +} diff --git a/dapr-spring/dapr-spring-openfeign/src/test/java/io/dapr/spring/openfeign/client/DaprInvokeMethodClient.java b/dapr-spring/dapr-spring-openfeign/src/test/java/io/dapr/spring/openfeign/client/DaprInvokeMethodClient.java new file mode 100644 index 0000000000..b12f7fa7e9 --- /dev/null +++ b/dapr-spring/dapr-spring-openfeign/src/test/java/io/dapr/spring/openfeign/client/DaprInvokeMethodClient.java @@ -0,0 +1,26 @@ +/* + * Copyright 2025 The Dapr 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 io.dapr.spring.openfeign.client; + +import io.dapr.spring.openfeign.annotation.UseDaprClient; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; + +@FeignClient(name = "invoke-method", url = "http://method.democlient/") +@UseDaprClient +public interface DaprInvokeMethodClient { + @GetMapping(value = "/hello", produces = "text/plain;charset=utf-8") + String getQuery(); +} diff --git a/dapr-spring/dapr-spring-openfeign/src/test/java/io/dapr/spring/openfeign/client/DemoFeignClient.java b/dapr-spring/dapr-spring-openfeign/src/test/java/io/dapr/spring/openfeign/client/DemoFeignClient.java new file mode 100644 index 0000000000..90b4f56471 --- /dev/null +++ b/dapr-spring/dapr-spring-openfeign/src/test/java/io/dapr/spring/openfeign/client/DemoFeignClient.java @@ -0,0 +1,21 @@ +/* + * Copyright 2025 The Dapr 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 io.dapr.spring.openfeign.client; + +import org.springframework.cloud.openfeign.FeignClient; + +@FeignClient(name = "demo-client", url = "http://dapr.io") +public interface DemoFeignClient { +} diff --git a/dapr-spring/dapr-spring-openfeign/src/test/resources/application.properties b/dapr-spring/dapr-spring-openfeign/src/test/resources/application.properties new file mode 100644 index 0000000000..a14ffccb40 --- /dev/null +++ b/dapr-spring/dapr-spring-openfeign/src/test/resources/application.properties @@ -0,0 +1 @@ +dapr.feign.enabled=true \ No newline at end of file diff --git a/dapr-spring/pom.xml b/dapr-spring/pom.xml index 593f493d80..1bda6dd9da 100644 --- a/dapr-spring/pom.xml +++ b/dapr-spring/pom.xml @@ -25,6 +25,8 @@ dapr-spring-boot-tests dapr-spring-boot-starters/dapr-spring-boot-starter dapr-spring-boot-starters/dapr-spring-boot-starter-test + dapr-openfeign-client + dapr-spring-openfeign diff --git a/dapr-spring/spotbugs-exclude.xml b/dapr-spring/spotbugs-exclude.xml index fc80592a14..74ebf5708a 100644 --- a/dapr-spring/spotbugs-exclude.xml +++ b/dapr-spring/spotbugs-exclude.xml @@ -8,4 +8,9 @@ + + + + + diff --git a/daprdocs/content/en/java-sdk-docs/spring-boot/_index.md b/daprdocs/content/en/java-sdk-docs/spring-boot/_index.md index 4a53e21b2b..4bb566f3c6 100644 --- a/daprdocs/content/en/java-sdk-docs/spring-boot/_index.md +++ b/daprdocs/content/en/java-sdk-docs/spring-boot/_index.md @@ -40,6 +40,47 @@ By adding these dependencies you can: - Use the Spring Data and Messaging abstractions and programming model that uses the Dapr APIs under the hood - Improve your inner-development loop by relying on [Testcontainers](https://testcontainers.com/) to bootstrap Dapr Control plane services and default components +___(Optional)___ And if you want to enable openfeign support, you will also need to add the dependencies to your project: + +``` + + org.springframework.cloud + spring-cloud-starter-openfeign + + + io.dapr.spring + dapr-spring-openfeign + 0.15.0-SNAPSHOT + +``` + +By adding these dependencies you can: +- Invoke Method and Bindings with OpenFeign, just like other HTTP endpoints. + +___Note that Spring Cloud dependencies will require a different dependencyManagement setup from normal SpringBoot Application, +please check the [Official Documentation](https://spring.io/projects/spring-cloud) for more information.___ + +___(Optional)___ If you want to use OpenFeign with Dapr from a non-SpringBoot project, you can add this dependency to your project: + +``` + + io.dapr.spring + dapr-openfeign-client + 0.15.0-SNAPSHOT + +``` + +It mainly provides a Client for OpenFeign to receive OpenFeign requests and send them using Dapr. + +You can use the client like this: + +```java +MyAppData response = Feign.builder().client(new DaprFeignClient()).target(MyAppData.class, + "http://binding.myBinding/create"); +``` + +___Note that you don't have to add this dependency to your SpringBoot project directly, `dapr-spring-openfeign` has already included it.___ + Once these dependencies are in your application, you can rely on Spring Boot autoconfiguration to autowire a `DaprClient` instance: ```java @@ -323,6 +364,55 @@ daprWorkflowClient.raiseEvent(instanceId, "MyEvenet", event); Check the [Dapr Workflow documentation](https://docs.dapr.io/developing-applications/building-blocks/workflow/workflow-overview/) for more information about how to work with Dapr Workflows. +## Invoke Methods and Bindings registered in Dapr with Spring Cloud OpenFeign + +First you should follow the official Spring Cloud OpenFeign steps to enable FeignClient features, +mainly by adding a `@UseFeignClient` annotation in your SpringBoot Application of Configurations. + +Define a FeignClient using DaprClient is very easy, you can just define a regular FeignClient, and add a `@UseFeignClient` to the interface, just like that: + +```java +@FeignClient(value = "producer-client", url = "http://method.producer-app/") +@UseDaprClient +public interface ProducerClient { + + @PostMapping("/orders") + String storeOrder(@RequestBody Order order); + + @GetMapping(value = "/orders", produces = "application/json") + Iterable getAll(); + + @GetMapping(value = "/orders/byItem/", produces = "application/json") + Iterable getAllByItem(@RequestParam("item") String item); +} +``` + +There you go! now when you call the ProducerClient methods, it will call the DaprClient to handle that. + +>___Note: because of the design of DaprClient, you won't get any headers from Dapr.___ +> +>___So you need to add `produces = "application/json"` +to your RequestMapping in order to parse the response body which return type is other than `String`.___ +> +> ___The `produces` field will generate an `Accept` header to the request, +the client will read it and create a fake `Content-Type` header to the response, +and Spring Cloud Openfeign will read the `Content-Type` header of the response to parse values.___ + +You may have noticed that the `url` field of `@FeignClient` is strange, here is the schema of it: + +The following content is from the Java Doc of DaprInvokeFeignClient. + +> Dapr currently supports two methods of invocation: invokeBinding (output binding) and invokeMethod. This client supports two modes: http://binding.xxx or http://method.xxx. The http scheme at the beginning is just to make Spring Boot Openfeign work properly. +> +> For invokeMethod, the URL contains two types of information, similar to the format of an HTTP URL. The difference lies in the conversion of the host in the HTTP URL to appId, and the path (excluding “/”) to methodName. For example, if you have a method with the appId “myApp” and the methodName “getAll/demo”, then the URL for this request would be http://method.myApp/getAll/demo. You can also set HTTP headers if you wish, and the client will handle them. Currently, only HTTP calls are supported, but grpc calls may be supported in the future, with possible URLs like http://method_grpc.myApp/getAll or similar. +> +> For invokeBinding, the URL also contains two types of information: the host is the bindingName, and the path is the operation. Note that different bindings support different operations, so you must consult the Dapr documentation. For example, if you have a binding with the bindingName “myBinding” and the supported operation is “create”, then the URL for this request would be http://binding.myBinding/create. You can put some metadata in the headers of the Feign request, and the client will handle them. +> +> As for the response, the result code is always 200 OK. If the client encounters any errors, it will throw an IOException. +> +> Currently, we have no method to gain metadata from server as Dapr Client doesn’t have methods to do that, so headers will be blank. If Accept header has set in request, a fake Content-Type header will be created in response, and it will be the first value of Accept header. + +___Note that not all bindings are recommended to use FeignClient to query directly, you can try `dapr-spring-data` for databases, or `dapr-spring-messaging` for pubsubs___ ## Next steps diff --git a/sdk-tests/pom.xml b/sdk-tests/pom.xml index ef79b94d93..df928fbf77 100644 --- a/sdk-tests/pom.xml +++ b/sdk-tests/pom.xml @@ -30,6 +30,7 @@ 3.25.5 1.41.0 3.4.3 + 2024.0.0 1.5.16 3.9.1 1.20.0 @@ -50,6 +51,13 @@ pom import + + org.springframework.cloud + spring-cloud-dependencies + ${springcloud.version} + pom + import + org.junit.platform junit-platform-commons @@ -255,6 +263,16 @@ junit-platform-engine test + + org.springframework.cloud + spring-cloud-starter-openfeign + test + + + io.dapr.spring + dapr-spring-openfeign + ${dapr.sdk.alpha.version} + org.testcontainers toxiproxy @@ -383,6 +401,7 @@ ${env.PRODUCT_SPRING_BOOT_VERSION} + ${env.PRODUCT_SPRING_CLOUD_VERSION} diff --git a/sdk-tests/src/test/java/io/dapr/it/spring/feign/DaprFeignIT.java b/sdk-tests/src/test/java/io/dapr/it/spring/feign/DaprFeignIT.java new file mode 100644 index 0000000000..3f59b7d28d --- /dev/null +++ b/sdk-tests/src/test/java/io/dapr/it/spring/feign/DaprFeignIT.java @@ -0,0 +1,123 @@ +/* + * Copyright 2025 The Dapr 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 io.dapr.it.spring.feign; + +import io.dapr.testcontainers.Component; +import io.dapr.testcontainers.DaprContainer; +import io.dapr.testcontainers.DaprLogLevel; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.testcontainers.containers.Network; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.util.List; +import java.util.Map; + +import static io.dapr.testcontainers.DaprContainerConstants.DAPR_RUNTIME_IMAGE_TAG; +import static org.junit.jupiter.api.Assertions.assertEquals; + +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT, + classes = { + DaprFeignTestApplication.class + }, + properties = { + "dapr.feign.retries=1", + "server.port=" + DaprFeignIT.APP_PORT + } +) +@Testcontainers +@Tag("testcontainers") +public class DaprFeignIT { + public static final String BINDING_NAME = "postgresbinding"; + private static final String CONNECTION_STRING = + "host=postgres-repository user=postgres password=password port=5432 connect_timeout=10 database=dapr_db_repository"; + private static final Map BINDING_PROPERTIES = Map.of("connectionString", CONNECTION_STRING); + private static final Network DAPR_NETWORK = Network.newNetwork(); + protected static final int APP_PORT = 8082; + private static final String SUBSCRIPTION_MESSAGE_PATTERN = ".*App entered healthy status.*"; + + @Container + private static final PostgreSQLContainer POSTGRE_SQL_CONTAINER = new PostgreSQLContainer<>("postgres:16-alpine") + .withNetworkAliases("postgres-repository") + .withDatabaseName("dapr_db_repository") + .withUsername("postgres") + .withPassword("password") + .withNetwork(DAPR_NETWORK); + + @Container + @ServiceConnection + private static final DaprContainer DAPR_CONTAINER = new DaprContainer(DAPR_RUNTIME_IMAGE_TAG) + .withAppName("dapr-feign-test") + .withNetwork(DAPR_NETWORK) + .withComponent(new Component(BINDING_NAME, "bindings.postgresql", "v1", BINDING_PROPERTIES)) + .withDaprLogLevel(DaprLogLevel.DEBUG) + .withAppPort(APP_PORT) + .withAppHealthCheckPath("/ready") + .withAppChannelAddress("host.testcontainers.internal") + .withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String())) + .dependsOn(POSTGRE_SQL_CONTAINER); + @Autowired + PostgreBindingClient postgreBindingClient; + @Autowired + TestMethodClient testMethodClient; + + @BeforeAll + public static void beforeAll() { + org.testcontainers.Testcontainers.exposeHostPorts(APP_PORT); + } + + @BeforeEach + public void beforeEach() { + // Ensure the subscriptions are registered + Wait.forLogMessage(SUBSCRIPTION_MESSAGE_PATTERN, 1).waitUntilReady(DAPR_CONTAINER); + } + + @Test + public void invokeBindingTest() { + postgreBindingClient.exec("CREATE TABLE \"demodata\" (\n" + + "\t\"id\" serial NOT NULL UNIQUE,\n" + + "\t\"name\" varchar(255) NOT NULL,\n" + + "\tPRIMARY KEY(\"id\")\n" + + ");", List.of()); + + postgreBindingClient.exec("INSERT INTO demodata (id, name) VALUES ($1, $2)", "[1, \"hello\"]"); + + assertEquals("[[1,\"hello\"]]", postgreBindingClient.query("SELECT * FROM demodata", List.of())); + } + + @Test + public void invokeSimpleGetMethodTest() { + assertEquals("hello", testMethodClient.hello()); + } + + @Test + public void invokeSimplePostMethodTest() { + assertEquals("hello", testMethodClient.echo("hello")); + } + + @Test + public void invokeJsonMethodTest() { + assertEquals("hello", testMethodClient.echoJson("hello").getMessage()); + } + +} diff --git a/sdk-tests/src/test/java/io/dapr/it/spring/feign/DaprFeignTestApplication.java b/sdk-tests/src/test/java/io/dapr/it/spring/feign/DaprFeignTestApplication.java new file mode 100644 index 0000000000..75ced791d9 --- /dev/null +++ b/sdk-tests/src/test/java/io/dapr/it/spring/feign/DaprFeignTestApplication.java @@ -0,0 +1,26 @@ +/* + * Copyright 2025 The Dapr 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 io.dapr.it.spring.feign; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.openfeign.EnableFeignClients; + +@SpringBootApplication +@EnableFeignClients +public class DaprFeignTestApplication { + public static void main(String[] args) { + SpringApplication.run(DaprFeignTestApplication.class, args); + } +} diff --git a/sdk-tests/src/test/java/io/dapr/it/spring/feign/PostgreBindingClient.java b/sdk-tests/src/test/java/io/dapr/it/spring/feign/PostgreBindingClient.java new file mode 100644 index 0000000000..3ca6ba38ed --- /dev/null +++ b/sdk-tests/src/test/java/io/dapr/it/spring/feign/PostgreBindingClient.java @@ -0,0 +1,37 @@ +/* + * Copyright 2025 The Dapr 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 io.dapr.it.spring.feign; + +import io.dapr.spring.openfeign.annotation.UseDaprClient; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestHeader; + +import java.util.List; + +import static io.dapr.it.spring.feign.DaprFeignIT.BINDING_NAME; + +@FeignClient(value = "postgres-binding", url = "http://binding." + BINDING_NAME) +@UseDaprClient +public interface PostgreBindingClient { + + @PostMapping("/exec") + void exec(@RequestHeader("sql") String sql, @RequestHeader("params") List params); + + @PostMapping("/exec") + void exec(@RequestHeader("sql") String sql, @RequestHeader("params") String params); + + @PostMapping("/query") + String query(@RequestHeader("sql") String sql, @RequestHeader("params") List params); +} diff --git a/sdk-tests/src/test/java/io/dapr/it/spring/feign/Result.java b/sdk-tests/src/test/java/io/dapr/it/spring/feign/Result.java new file mode 100644 index 0000000000..4150e616b8 --- /dev/null +++ b/sdk-tests/src/test/java/io/dapr/it/spring/feign/Result.java @@ -0,0 +1,33 @@ +/* + * Copyright 2025 The Dapr 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 io.dapr.it.spring.feign; + +public class Result { + private String message; + + public Result() { + } + + public Result(String message) { + this.message = message; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } +} diff --git a/sdk-tests/src/test/java/io/dapr/it/spring/feign/TestMethodClient.java b/sdk-tests/src/test/java/io/dapr/it/spring/feign/TestMethodClient.java new file mode 100644 index 0000000000..25656a47b6 --- /dev/null +++ b/sdk-tests/src/test/java/io/dapr/it/spring/feign/TestMethodClient.java @@ -0,0 +1,35 @@ +/* + * Copyright 2025 The Dapr 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 io.dapr.it.spring.feign; + +import io.dapr.spring.openfeign.annotation.UseDaprClient; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + +@FeignClient(value = "test-method", url = "http://method.dapr-feign-test/") +@UseDaprClient +public interface TestMethodClient { + + @GetMapping(value = "/hello") + String hello(); + + @PostMapping("/echo") + String echo(@RequestBody String input); + + @PostMapping(value = "/echoj", produces = "application/json;charset=utf-8") + Result echoJson(@RequestBody String input); + +} diff --git a/sdk-tests/src/test/java/io/dapr/it/spring/feign/TestRestController.java b/sdk-tests/src/test/java/io/dapr/it/spring/feign/TestRestController.java new file mode 100644 index 0000000000..a3a6ba007c --- /dev/null +++ b/sdk-tests/src/test/java/io/dapr/it/spring/feign/TestRestController.java @@ -0,0 +1,46 @@ +/* + * Copyright 2025 The Dapr 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 io.dapr.it.spring.feign; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class TestRestController { + + public static final String pubSubName = "pubsub"; + public static final String topicName = "mockTopic"; + + @GetMapping("/ready") + public String ok() { + return "OK"; + } + + @GetMapping("/hello") + public String hello() { + return "hello"; + } + + @PostMapping("/echo") + public String echo(@RequestBody String input) { + return input; + } + + @PostMapping("/echoj") + public Result echoJson(@RequestBody String input) { + return new Result(input); + } +} diff --git a/spring-boot-examples/README.md b/spring-boot-examples/README.md index 3cc88610de..a6ba771502 100644 --- a/spring-boot-examples/README.md +++ b/spring-boot-examples/README.md @@ -1,12 +1,14 @@ # Dapr Spring Boot and Testcontainers integration Example -This example consists of two applications: +This example consists of three applications: - Producer App: - Publish messages using a Spring Messaging approach - Store and retrieve information using Spring Data CrudRepository - Implements a Workflow with Dapr Workflows - Consumer App: - Subscribe to messages +- OpenFeign App: + - A proxy to Producer App Order API ## Running these examples from source code @@ -27,7 +29,7 @@ expected_stdout_lines: background: true expected_return_code: 143 sleep: 30 -timeout_seconds: 45 +timeout_seconds: 75 --> @@ -68,6 +70,29 @@ cd consumer-app/ The `consumer-app` starts in port `8081` by default. +And then run in a different terminal: + + + + +```sh +cd openfeign-app/ +../../mvnw -Dspring-boot.run.arguments="--reuse=true" spring-boot:test-run +``` + + +The `openfeign-app` starts in port `8083` by default. + ## Interacting with the applications Now that both applications are up you can place an order by sending a POST request to `:8080/orders/` @@ -92,6 +117,26 @@ curl -X POST localhost:8080/orders -H 'Content-Type: application/json' -d '{ "it +You can also send POST request to the `openfeign-app`, which redirect the request to the `producer-app`: + + + + +```sh +curl -X POST localhost:8083/rpc/producer/orders -H 'Content-Type: application/json' -d '{ "item": "the mars volta EP", "amount": 1 }' +``` + + + If you check the `producer-app` logs you should see the following lines: diff --git a/spring-boot-examples/kubernetes/README.md b/spring-boot-examples/kubernetes/README.md index 9f047d76f3..9eda5ea755 100644 --- a/spring-boot-examples/kubernetes/README.md +++ b/spring-boot-examples/kubernetes/README.md @@ -1,7 +1,7 @@ # Running this example on Kubernetes To run this example on Kubernetes, you can use any Kubernetes distribution. -We install Dapr on a Kubernetes cluster and then we will deploy both the `producer-app` and `consumer-app`. +We install Dapr on a Kubernetes cluster and then we will deploy the `producer-app`, `consumer-app` and `openfeign-app`. ## Creating a cluster and installing Dapr @@ -77,6 +77,24 @@ docker push localhost:5001/sb-consumer-app podman push localhost:5001/sb-consumer-app --tls-verify=false ``` +From inside the `spring-boot-examples/openfeign-app` directory you can run the following command to create a container: +```bash +mvn spring-boot:build-image +``` + +Once we have the container image created, we need to tag and push to the local registry, so the image can be used from our local cluster. +Alternatively, you can push the images to a public registry and update the Kubernetes manifests accordingly. + +```bash +docker tag openfeign-app:0.15.0-SNAPSHOT localhost:5001/sb-openfeign-app +docker push localhost:5001/sb-openfeign-app +``` + +**Note**: for Podman you need to run: +``` +podman push localhost:5001/sb-openfeign-app --tls-verify=false +``` + Now we are ready to install our application into the cluster. ## Installing and interacting with the application @@ -107,7 +125,7 @@ Next you need to use `kubectl port-forward` to be able to send requests to the a kubectl port-forward svc/producer-app 8080:8080 ``` -In a different terminals you can check the logs of the `producer-app` and `consumer-app`: +In a different terminals you can check the logs of the `producer-app`, `consumer-app` and `openfeign-app`: ```bash kubectl logs -f producer-app- @@ -117,5 +135,8 @@ and ```bash kubectl logs -f consumer-app- ``` +and - +```bash +kubectl logs -f openfeign-app- +``` diff --git a/spring-boot-examples/kubernetes/openfeign-app.yaml b/spring-boot-examples/kubernetes/openfeign-app.yaml new file mode 100644 index 0000000000..40dff61ff2 --- /dev/null +++ b/spring-boot-examples/kubernetes/openfeign-app.yaml @@ -0,0 +1,45 @@ +apiVersion: v1 +kind: Service +metadata: + labels: + app: openfeign-app + name: openfeign-app +spec: + type: NodePort + ports: + - name: "openfeign-app" + port: 8083 + targetPort: 8083 + nodePort: 31003 + selector: + app: openfeign-app + +--- + +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: openfeign-app + name: openfeign-app +spec: + replicas: 1 + selector: + matchLabels: + app: openfeign-app + template: + metadata: + annotations: + dapr.io/app-id: openfeign-app + dapr.io/app-port: "8083" + dapr.io/enabled: "true" + labels: + app: openfeign-app + spec: + containers: + - image: localhost:5001/sb-openfeign-app + name: openfeign-app + imagePullPolicy: Always + ports: + - containerPort: 8083 + name: openfeign-app diff --git a/spring-boot-examples/openfeign-app/pom.xml b/spring-boot-examples/openfeign-app/pom.xml new file mode 100644 index 0000000000..a5d6070e4a --- /dev/null +++ b/spring-boot-examples/openfeign-app/pom.xml @@ -0,0 +1,61 @@ + + + 4.0.0 + + io.dapr + spring-boot-examples + 0.15.0-SNAPSHOT + + + openfeign-app + openfgien-app + Spring Boot, Testcontainers and Dapr Integration Examples :: OpenFeign App + + + 17 + 17 + UTF-8 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-actuator + + + io.dapr.spring + dapr-spring-boot-starter + + + org.springframework.cloud + spring-cloud-starter-openfeign + + + io.dapr.spring + dapr-spring-openfeign + ${dapr.sdk.alpha.version} + + + io.dapr.spring + dapr-spring-boot-starter-test + test + + + org.testcontainers + junit-jupiter + test + + + io.rest-assured + rest-assured + test + + + + \ No newline at end of file diff --git a/spring-boot-examples/openfeign-app/src/main/java/io/dapr/springboot/examples/openfeign/OpenFeignApplication.java b/spring-boot-examples/openfeign-app/src/main/java/io/dapr/springboot/examples/openfeign/OpenFeignApplication.java new file mode 100644 index 0000000000..0dafc1af14 --- /dev/null +++ b/spring-boot-examples/openfeign-app/src/main/java/io/dapr/springboot/examples/openfeign/OpenFeignApplication.java @@ -0,0 +1,28 @@ +/* + * Copyright 2025 The Dapr 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 io.dapr.springboot.examples.openfeign; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.openfeign.EnableFeignClients; + +@SpringBootApplication +@EnableFeignClients +public class OpenFeignApplication { + + public static void main(String[] args) { + SpringApplication.run(OpenFeignApplication.class, args); + } + +} diff --git a/spring-boot-examples/openfeign-app/src/main/java/io/dapr/springboot/examples/openfeign/Order.java b/spring-boot-examples/openfeign-app/src/main/java/io/dapr/springboot/examples/openfeign/Order.java new file mode 100644 index 0000000000..7917bbb605 --- /dev/null +++ b/spring-boot-examples/openfeign-app/src/main/java/io/dapr/springboot/examples/openfeign/Order.java @@ -0,0 +1,52 @@ +/* + * Copyright 2025 The Dapr 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 io.dapr.springboot.examples.openfeign; + +public class Order { + private String id; + private String item; + private Integer amount; + + public Order() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getItem() { + return item; + } + + public void setItem(String item) { + this.item = item; + } + + public Integer getAmount() { + return amount; + } + + public void setAmount(Integer amount) { + this.amount = amount; + } + + @Override + public String toString() { + return "Order{" + "id='" + id + '\'' + ", item='" + item + '\'' + ", amount=" + amount + '}'; + } +} diff --git a/spring-boot-examples/openfeign-app/src/main/java/io/dapr/springboot/examples/openfeign/ProducerClientRestController.java b/spring-boot-examples/openfeign-app/src/main/java/io/dapr/springboot/examples/openfeign/ProducerClientRestController.java new file mode 100644 index 0000000000..2eafdb9fa2 --- /dev/null +++ b/spring-boot-examples/openfeign-app/src/main/java/io/dapr/springboot/examples/openfeign/ProducerClientRestController.java @@ -0,0 +1,47 @@ +/* + * Copyright 2025 The Dapr 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 io.dapr.springboot.examples.openfeign; + +import io.dapr.springboot.examples.openfeign.client.ProducerClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/rpc/producer") +public class ProducerClientRestController { + private final ProducerClient producerClient; + + public ProducerClientRestController(ProducerClient producerClient) { + this.producerClient = producerClient; + } + + @PostMapping("/orders") + public String storeOrder(@RequestBody Order order) { + return producerClient.storeOrder(order); + } + + @GetMapping("/orders") + public Iterable getAll() { + return producerClient.getAll(); + } + + @GetMapping("/orders/byItem/") + public Iterable getAllByItem(@RequestParam("item") String item) { + return producerClient.getAllByItem(item); + } +} diff --git a/spring-boot-examples/openfeign-app/src/main/java/io/dapr/springboot/examples/openfeign/client/ProducerClient.java b/spring-boot-examples/openfeign-app/src/main/java/io/dapr/springboot/examples/openfeign/client/ProducerClient.java new file mode 100644 index 0000000000..1c97e8d123 --- /dev/null +++ b/spring-boot-examples/openfeign-app/src/main/java/io/dapr/springboot/examples/openfeign/client/ProducerClient.java @@ -0,0 +1,36 @@ +/* + * Copyright 2025 The Dapr 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 io.dapr.springboot.examples.openfeign.client; + +import io.dapr.spring.openfeign.annotation.UseDaprClient; +import io.dapr.springboot.examples.openfeign.Order; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + +@FeignClient(value = "producer-client", url = "http://method.producer-app/") +@UseDaprClient +public interface ProducerClient { + + @PostMapping("/orders") + String storeOrder(@RequestBody Order order); + + @GetMapping(value = "/orders", produces = "application/json") + Iterable getAll(); + + @GetMapping(value = "/orders/byItem/", produces = "application/json") + Iterable getAllByItem(@RequestParam("item") String item); +} diff --git a/spring-boot-examples/openfeign-app/src/main/resources/application.properties b/spring-boot-examples/openfeign-app/src/main/resources/application.properties new file mode 100644 index 0000000000..0ff707082e --- /dev/null +++ b/spring-boot-examples/openfeign-app/src/main/resources/application.properties @@ -0,0 +1,2 @@ +spring.application.name=openfeign-app +server.port=8083 diff --git a/spring-boot-examples/openfeign-app/src/test/java/io/dapr/springboot/examples/openfeign/DaprTestContainersConfig.java b/spring-boot-examples/openfeign-app/src/test/java/io/dapr/springboot/examples/openfeign/DaprTestContainersConfig.java new file mode 100644 index 0000000000..37385db7c6 --- /dev/null +++ b/spring-boot-examples/openfeign-app/src/test/java/io/dapr/springboot/examples/openfeign/DaprTestContainersConfig.java @@ -0,0 +1,84 @@ +/* + * Copyright 2025 The Dapr 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 io.dapr.springboot.examples.openfeign; + +import io.dapr.testcontainers.DaprContainer; +import io.dapr.testcontainers.DaprLogLevel; +import org.junit.runner.Description; +import org.junit.runners.model.Statement; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Bean; +import org.springframework.core.env.Environment; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.Network; + +import java.util.List; + +import static io.dapr.testcontainers.DaprContainerConstants.DAPR_RUNTIME_IMAGE_TAG; + +@TestConfiguration(proxyBeanMethods = false) +public class DaprTestContainersConfig { + + @Bean + public Network getDaprNetwork(Environment env) { + boolean reuse = env.getProperty("reuse", Boolean.class, false); + if (reuse) { + Network defaultDaprNetwork = new Network() { + @Override + public String getId() { + return "dapr-network"; + } + + @Override + public void close() { + + } + + @Override + public Statement apply(Statement base, Description description) { + return null; + } + }; + + List networks = DockerClientFactory.instance().client().listNetworksCmd() + .withNameFilter("dapr-network").exec(); + if (networks.isEmpty()) { + Network.builder().createNetworkCmdModifier(cmd -> cmd.withName("dapr-network")).build().getId(); + return defaultDaprNetwork; + } else { + return defaultDaprNetwork; + } + } else { + return Network.newNetwork(); + } + } + + + @Bean + @ServiceConnection + public DaprContainer daprContainer(Network daprNetwork, Environment env) { + boolean reuse = env.getProperty("reuse", Boolean.class, false); + + return new DaprContainer(DAPR_RUNTIME_IMAGE_TAG) + .withAppName("openfeign-app") + .withNetwork(daprNetwork) + .withDaprLogLevel(DaprLogLevel.INFO) + .withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String())) + .withAppPort(8083).withAppChannelAddress("host.testcontainers.internal") + .withReusablePlacement(reuse) + .withAppHealthCheckPath("/actuator/health"); + } + +} diff --git a/spring-boot-examples/openfeign-app/src/test/java/io/dapr/springboot/examples/openfeign/OpenFeignAppTests.java b/spring-boot-examples/openfeign-app/src/test/java/io/dapr/springboot/examples/openfeign/OpenFeignAppTests.java new file mode 100644 index 0000000000..d9533b9626 --- /dev/null +++ b/spring-boot-examples/openfeign-app/src/test/java/io/dapr/springboot/examples/openfeign/OpenFeignAppTests.java @@ -0,0 +1,81 @@ +/* + * Copyright 2025 The Dapr 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 io.dapr.springboot.examples.openfeign; + +import io.dapr.client.DaprClient; +import io.dapr.client.domain.InvokeMethodRequest; +import io.dapr.springboot.DaprAutoConfiguration; +import io.dapr.utils.TypeRef; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import reactor.core.publisher.Mono; + +import java.nio.charset.StandardCharsets; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.CoreMatchers.is; + + +@SpringBootTest(classes = {TestConsumerApplication.class, DaprTestContainersConfig.class, + DaprAutoConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT) +class OpenFeignAppTests { + + @MockitoBean + private DaprClient daprClient; + + + @BeforeAll + public static void setup() { + org.testcontainers.Testcontainers.exposeHostPorts(8083); + } + + @BeforeEach + void setUp() { + RestAssured.baseURI = "http://localhost:" + 8083; + Mockito.when(daprClient.invokeMethod(Mockito.any(InvokeMethodRequest.class), Mockito.eq(TypeRef.BYTE_ARRAY))) + .thenReturn(Mono.just("[]".getBytes(StandardCharsets.UTF_8))); + } + + @Test + public void demoClientTest() { + given().contentType(ContentType.JSON) + .body("{ \"id\": \"abc-123\",\"item\": \"the mars volta LP\",\"amount\": 1}") + .when() + .post("/rpc/producer/orders") + .then() + .statusCode(200); + + given().contentType(ContentType.JSON) + .when() + .get("/rpc/producer/orders") + .then() + .statusCode(200).body("size()", is(0)); + + given().contentType(ContentType.JSON) + .when() + .queryParam("item", "the mars volta LP") + .get("/rpc/producer/orders/byItem/") + .then() + .statusCode(200).body("size()", is(0)); + } + + +} diff --git a/spring-boot-examples/openfeign-app/src/test/java/io/dapr/springboot/examples/openfeign/TestConsumerApplication.java b/spring-boot-examples/openfeign-app/src/test/java/io/dapr/springboot/examples/openfeign/TestConsumerApplication.java new file mode 100644 index 0000000000..41f0bb9dcb --- /dev/null +++ b/spring-boot-examples/openfeign-app/src/test/java/io/dapr/springboot/examples/openfeign/TestConsumerApplication.java @@ -0,0 +1,31 @@ +/* + * Copyright 2025 The Dapr 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 io.dapr.springboot.examples.openfeign; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + + +@SpringBootApplication +public class TestConsumerApplication { + + public static void main(String[] args) { + SpringApplication.from(OpenFeignApplication::main) + .with(DaprTestContainersConfig.class) + .run(args); + org.testcontainers.Testcontainers.exposeHostPorts(8083); + } + + +} diff --git a/spring-boot-examples/pom.xml b/spring-boot-examples/pom.xml index 75a32364f7..7ad23c618b 100644 --- a/spring-boot-examples/pom.xml +++ b/spring-boot-examples/pom.xml @@ -16,11 +16,13 @@ true 3.4.3 + 2024.0.0 producer-app consumer-app + openfeign-app @@ -32,6 +34,13 @@ pom import + + org.springframework.cloud + spring-cloud-dependencies + ${springcloud.version} + pom + import +