From 053deb52104084f13c8fb01cffc699509e530001 Mon Sep 17 00:00:00 2001 From: Olga Maciaszek-Sharma Date: Mon, 16 Jun 2025 13:55:44 +0200 Subject: [PATCH] Init draft. Signed-off-by: Olga Maciaszek-Sharma --- ...tractCloudHttpClientServiceProperties.java | 17 ++ .../CloudHttpClientServiceProperties.java | 30 +++ .../CircuitBreakerRequestValueProcessor.java | 24 +++ .../CircuitBreakerRestClientAdapter.java | 186 ++++++++++++++++++ ...rRestClientHttpServiceGroupConfigurer.java | 53 +++++ 5 files changed, 310 insertions(+) create mode 100644 spring-cloud-commons/src/main/java/org/springframework/cloud/client/AbstractCloudHttpClientServiceProperties.java create mode 100644 spring-cloud-commons/src/main/java/org/springframework/cloud/client/CloudHttpClientServiceProperties.java create mode 100644 spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRequestValueProcessor.java create mode 100644 spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientAdapter.java create mode 100644 spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientHttpServiceGroupConfigurer.java diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/AbstractCloudHttpClientServiceProperties.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/AbstractCloudHttpClientServiceProperties.java new file mode 100644 index 00000000..de6ac005 --- /dev/null +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/AbstractCloudHttpClientServiceProperties.java @@ -0,0 +1,17 @@ +package org.springframework.cloud.client; + +/** + * @author Olga Maciaszek-Sharma + */ +public abstract class AbstractCloudHttpClientServiceProperties { + + private String fallbackClass; + + public String getFallbackClass() { + return fallbackClass; + } + + public void setFallbackClass(String fallbackClass) { + this.fallbackClass = fallbackClass; + } +} diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/CloudHttpClientServiceProperties.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/CloudHttpClientServiceProperties.java new file mode 100644 index 00000000..671a755d --- /dev/null +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/CloudHttpClientServiceProperties.java @@ -0,0 +1,30 @@ +package org.springframework.cloud.client; + +import java.util.LinkedHashMap; +import java.util.Map; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * @author Olga Maciaszek-Sharma + */ +@ConfigurationProperties("spring.cloud.http.client.service") +public class CloudHttpClientServiceProperties extends AbstractCloudHttpClientServiceProperties { + + private Map group = new LinkedHashMap<>(); + + public Map getGroup() { + return this.group; + } + + public void setGroup(Map group) { + this.group = group; + } + + /** + * Properties for a single HTTP Service client group. + */ + public static class Group extends AbstractCloudHttpClientServiceProperties { + + } +} diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRequestValueProcessor.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRequestValueProcessor.java new file mode 100644 index 00000000..5b936b07 --- /dev/null +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRequestValueProcessor.java @@ -0,0 +1,24 @@ +package org.springframework.cloud.client.circuitbreaker; + +import java.lang.reflect.Method; + +import org.jspecify.annotations.Nullable; + +import org.springframework.web.service.invoker.HttpRequestValues; + +/** + * @author Olga Maciaszek-Sharma + */ +public class CircuitBreakerRequestValueProcessor implements HttpRequestValues.Processor { + + public static final String METHOD_ATTRIBUTE_NAME = "spring.cloud.method.name"; + public static final String PARAMETER_TYPES_ATTRIBUTE_NAME = "spring.cloud.method.parameter-types"; + public static final String ARGUMENTS_ATTRIBUTE_NAME = "spring.cloud.method.arguments"; + + @Override + public void process(Method method, @Nullable Object[] arguments, HttpRequestValues.Builder builder) { + builder.addAttribute(METHOD_ATTRIBUTE_NAME, method.getName()); + builder.addAttribute(PARAMETER_TYPES_ATTRIBUTE_NAME, method.getParameterTypes()); + builder.addAttribute(ARGUMENTS_ATTRIBUTE_NAME, arguments); + } +} diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientAdapter.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientAdapter.java new file mode 100644 index 00000000..7197b004 --- /dev/null +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientAdapter.java @@ -0,0 +1,186 @@ +package org.springframework.cloud.client.circuitbreaker; + +import java.io.InputStream; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.jspecify.annotations.Nullable; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpCookie; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.http.StreamingHttpOutputMessage; +import org.springframework.util.Assert; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.support.RestClientAdapter; +import org.springframework.web.service.invoker.HttpExchangeAdapter; +import org.springframework.web.service.invoker.HttpRequestValues; +import org.springframework.web.util.UriBuilderFactory; + +/** + * @author Olga Maciaszek-Sharma + * @author Rossen Stoyanchev + */ +public class CircuitBreakerRestClientAdapter implements HttpExchangeAdapter { + + // FIXME: get fallbacks + + private final RestClient restClient; + private final CircuitBreaker circuitBreaker; + private final Class fallbacks; + + private CircuitBreakerRestClientAdapter(RestClient restClient, CircuitBreaker circuitBreaker, + // TODO: generics + Class fallbacks) { + this.restClient = restClient; + this.circuitBreaker = circuitBreaker; + this.fallbacks = fallbacks; + } + + + @Override + public boolean supportsRequestAttributes() { + return true; + } + + @Override + public void exchange(HttpRequestValues requestValues) { + Map attributes = requestValues.getAttributes(); + String methodName = String.valueOf(attributes + .get(CircuitBreakerRequestValueProcessor.METHOD_ATTRIBUTE_NAME)); + Class[] parameterTypes = (Class[]) attributes + .get(CircuitBreakerRequestValueProcessor.PARAMETER_TYPES_ATTRIBUTE_NAME); + Method method; + try { + method = fallbacks.getMethod(methodName, parameterTypes); + method.setAccessible(true); + } + catch (NoSuchMethodException e) { + // TODO + throw new RuntimeException(e); + } + circuitBreaker.run(() -> newRequest(requestValues).retrieve().toBodilessEntity(), + throwable -> { + try { + return method.invoke(this, + attributes.get(CircuitBreakerRequestValueProcessor.ARGUMENTS_ATTRIBUTE_NAME)); + } + catch (IllegalAccessException | InvocationTargetException e) { + // TODO + throw new RuntimeException(e); + } + }); + } + + @Override + public HttpHeaders exchangeForHeaders(HttpRequestValues values) { + return circuitBreaker.run(() -> newRequest(values).retrieve().toBodilessEntity() + .getHeaders()); + } + + @SuppressWarnings("unchecked") + @Override + public @Nullable T exchangeForBody(HttpRequestValues values, ParameterizedTypeReference bodyType) { + return circuitBreaker.run(() -> { + if (bodyType.getType().equals(InputStream.class)) { + return (T) newRequest(values).exchange((request, response) -> response.getBody(), false); + } + return newRequest(values).retrieve().body(bodyType); + }); + } + + @Override + public ResponseEntity exchangeForBodilessEntity(HttpRequestValues values) { + return circuitBreaker.run(() -> newRequest(values).retrieve().toBodilessEntity()); + } + + @SuppressWarnings("unchecked") + @Override + public ResponseEntity exchangeForEntity(HttpRequestValues values, ParameterizedTypeReference bodyType) { + return circuitBreaker.run(() -> { + if (bodyType.getType().equals(InputStream.class)) { + return (ResponseEntity) newRequest(values).exchangeForRequiredValue((request, response) -> + ResponseEntity.status(response.getStatusCode()) + .headers(response.getHeaders()) + .body(response.getBody()), false); + } + return newRequest(values).retrieve().toEntity(bodyType); + }); + } + + @SuppressWarnings("unchecked") + private RestClient.RequestBodySpec newRequest(HttpRequestValues values) { + + HttpMethod httpMethod = values.getHttpMethod(); + Assert.notNull(httpMethod, "HttpMethod is required"); + + RestClient.RequestBodyUriSpec uriSpec = this.restClient.method(httpMethod); + + RestClient.RequestBodySpec bodySpec; + if (values.getUri() != null) { + bodySpec = uriSpec.uri(values.getUri()); + } + else if (values.getUriTemplate() != null) { + UriBuilderFactory uriBuilderFactory = values.getUriBuilderFactory(); + if (uriBuilderFactory != null) { + URI uri = uriBuilderFactory.expand(values.getUriTemplate(), values.getUriVariables()); + bodySpec = uriSpec.uri(uri); + } + else { + bodySpec = uriSpec.uri(values.getUriTemplate(), values.getUriVariables()); + } + } + else { + throw new IllegalStateException("Neither full URL nor URI template"); + } + + bodySpec.headers(headers -> headers.putAll(values.getHeaders())); + + if (!values.getCookies().isEmpty()) { + List cookies = new ArrayList<>(); + values.getCookies() + .forEach((name, cookieValues) -> cookieValues.forEach(value -> { + HttpCookie cookie = new HttpCookie(name, value); + cookies.add(cookie.toString()); + })); + bodySpec.header(HttpHeaders.COOKIE, String.join("; ", cookies)); + } + + if (values.getApiVersion() != null) { + bodySpec.apiVersion(values.getApiVersion()); + } + + bodySpec.attributes(attributes -> attributes.putAll(values.getAttributes())); + + B body = (B) values.getBodyValue(); + if (body != null) { + if (body instanceof StreamingHttpOutputMessage.Body streamingBody) { + bodySpec.body(streamingBody); + } + else if (values.getBodyValueType() != null) { + bodySpec.body(body, (ParameterizedTypeReference) values.getBodyValueType()); + } + else { + bodySpec.body(body); + } + } + + return bodySpec; + } + + + /** + * Create a {@link RestClientAdapter} for the given {@link RestClient}. + */ + public static CircuitBreakerRestClientAdapter create(RestClient restClient, CircuitBreaker circuitBreaker, + Class fallbacks) { + return new CircuitBreakerRestClientAdapter(restClient, circuitBreaker, fallbacks); + } + +} diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientHttpServiceGroupConfigurer.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientHttpServiceGroupConfigurer.java new file mode 100644 index 00000000..b48e5376 --- /dev/null +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientHttpServiceGroupConfigurer.java @@ -0,0 +1,53 @@ +package org.springframework.cloud.client.circuitbreaker; + +import org.springframework.cloud.client.CloudHttpClientServiceProperties; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.support.RestClientHttpServiceGroupConfigurer; + +/** + * @author Olga Maciaszek-Sharma + */ +public class CircuitBreakerRestClientHttpServiceGroupConfigurer implements RestClientHttpServiceGroupConfigurer { + + // Make sure Boot's configurers run before + private static final int ORDER = 11; + + private final CloudHttpClientServiceProperties clientServiceProperties; + + public CircuitBreakerRestClientHttpServiceGroupConfigurer(CloudHttpClientServiceProperties clientServiceProperties) { + this.clientServiceProperties = clientServiceProperties; + } + + @Override + public void configureGroups(Groups groups) { + groups.forEachGroup((group, clientBuilder, factoryBuilder) -> { + String groupName = group.name(); + CloudHttpClientServiceProperties.Group groupProperties = clientServiceProperties.getGroup() + .get(groupName); + String fallbackClass = groupProperties == null ? null : groupProperties.getFallbackClass(); + factoryBuilder.httpRequestValuesProcessor(new CircuitBreakerRequestValueProcessor()); + Class fallbacks = null; + try { + fallbacks = Class.forName(fallbackClass); + } + catch (ClassNotFoundException e) { + // TODO + throw new RuntimeException(e); + } + // TODO: change to decorator + factoryBuilder.exchangeAdapter(CircuitBreakerRestClientAdapter.create(RestClient.builder() + .build(), buildCircuitBreaker(), fallbacks)); + }); + } + + + private CircuitBreaker buildCircuitBreaker() { + return null; + } + + + @Override + public int getOrder() { + return ORDER; + } +}