Improved SDK Setup - defaults (#3027)

Signed-off-by: Avgustin Marinov <Avgustin.Marinov@bosch.com>
This commit is contained in:
Avgustin Marinov
2026-04-17 16:48:43 +03:00
committed by GitHub
parent b4a171b4db
commit e9aa13e68f
10 changed files with 164 additions and 143 deletions

View File

@@ -10,15 +10,18 @@
package org.eclipse.hawkbit.sdk;
import java.io.BufferedOutputStream;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.Reader;
import java.lang.annotation.Annotation;
import java.lang.ref.Cleaner;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Proxy;
import java.lang.reflect.Type;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URISyntaxException;
@@ -44,12 +47,15 @@ import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import com.fasterxml.jackson.annotation.JsonInclude;
import feign.Contract;
import feign.Feign;
import feign.FeignException;
import feign.Request;
import feign.RequestInterceptor;
import feign.RequestTemplate;
import feign.Response;
import feign.Util;
import feign.codec.Decoder;
import feign.codec.Encoder;
import feign.codec.ErrorDecoder;
@@ -72,7 +78,12 @@ import org.apache.hc.client5.http.ssl.TrustAllStrategy;
import org.apache.hc.core5.http.ClassicHttpResponse;
import org.apache.hc.core5.ssl.SSLContextBuilder;
import org.apache.hc.core5.util.TimeValue;
import org.jspecify.annotations.NonNull;
import org.springframework.cloud.openfeign.support.ResponseEntityDecoder;
import org.springframework.cloud.openfeign.support.SpringMvcContract;
import org.springframework.core.io.InputStreamResource;
import org.springframework.hateoas.Link;
import org.springframework.hateoas.mediatype.hal.HalJacksonModule;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
@@ -84,7 +95,11 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.multipart.MultipartFile;
import tools.jackson.databind.DeserializationFeature;
import tools.jackson.databind.JavaType;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.SerializationFeature;
import tools.jackson.databind.json.JsonMapper;
@Slf4j
@Builder
@@ -110,12 +125,6 @@ public class HawkbitClient {
} // else do not send authentication, no authentication or certificate based
};
// @formatter:on
private static final ErrorDecoder DEFAULT_ERROR_DECODER_0 = new ErrorDecoder.Default();
public static final ErrorDecoder DEFAULT_ERROR_DECODER = (methodKey, response) -> {
final Exception e = DEFAULT_ERROR_DECODER_0.decode(methodKey, response);
log.trace("REST API call failed!", e);
return e;
};
private static final HttpRequestRetryStrategy DEFAULT_HTTP_REQUEST_RETRY_STRATEGY =
new DefaultHttpRequestRetryStrategy(
@@ -134,6 +143,10 @@ public class HawkbitClient {
private final HttpRequestRetryStrategy httpRequestRetryStrategy;
public HawkbitClient(final HawkbitServer hawkBitServer) {
this(hawkBitServer, null, null, null, null, null);
}
public HawkbitClient(final HawkbitServer hawkBitServer, final Encoder encoder, final Decoder decoder, final Contract contract) {
this(hawkBitServer, encoder, decoder, contract, null, null);
}
@@ -149,9 +162,9 @@ public class HawkbitClient {
final ErrorDecoder errorDecoder, final BiFunction<Tenant, Controller, RequestInterceptor> requestInterceptorFn,
final HttpRequestRetryStrategy httpRequestRetryStrategy) {
this.hawkBitServer = hawkBitServer;
this.encoder = encoder;
this.decoder = decoder;
this.contract = contract;
this.encoder = encoder == null ? DEFAULT_ENCODER : encoder;
this.decoder = decoder == null ? DEFAULT_DECODER : decoder;
this.contract = contract == null ? DEFAULT_CONTRACT : contract;
this.errorDecoder = errorDecoder == null ? DEFAULT_ERROR_DECODER : errorDecoder;
this.requestInterceptorFn = requestInterceptorFn == null ? DEFAULT_REQUEST_INTERCEPTOR_FN : requestInterceptorFn;
@@ -510,4 +523,84 @@ public class HawkbitClient {
}
}
}
public static final Encoder DEFAULT_ENCODER = new Encoder() {
private static final ObjectMapper OBJECT_MAPPER = JsonMapper.builder()
.changeDefaultPropertyInclusion(incl -> incl.withValueInclusion(JsonInclude.Include.NON_NULL))
.configure(SerializationFeature.INDENT_OUTPUT, true)
.build();
@Override
public void encode(final Object object, final Type bodyType, final RequestTemplate template) {
final JavaType javaType = OBJECT_MAPPER.getTypeFactory().constructType(bodyType);
template.body(OBJECT_MAPPER.writerFor(javaType).writeValueAsBytes(object), Util.UTF_8);
}
};
/**
* A decorator for the {@link ResponseEntityDecoder} that extends it whit hal-json and octet streams support.
*/
public static final Decoder DEFAULT_DECODER = new Decoder() {
private static final String OCTET_STREAM = "[application/octet-stream]";
private static final String OCTET_STREAM_UTF8 = "[application/octet-stream;charset=UTF-8]";
private static final String TEXT_PLAIN = "[text/plain]";
private static final String TEXT_PLAIN_UTF8 = "[text/plain;charset=UTF-8]";
private static final ObjectMapper OBJECT_MAPPER = JsonMapper.builder()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.addModule(new HalJacksonModule())
.build();
private final ResponseEntityDecoder delegate = new ResponseEntityDecoder((response, type) -> {
if (response.status() == 404 || response.status() == 204) {
return Util.emptyValueOf(type);
} else if (response.body() == null) {
return null;
} else {
final Reader reader = markSupportedReader(response.body().asReader(response.charset()));
reader.mark(1);
if (reader.read() == -1) {
return null;
}
reader.reset();
return OBJECT_MAPPER.readValue(reader, OBJECT_MAPPER.constructType(type));
}
});
@Override
public Object decode(final Response response, final Type type) throws IOException {
if (type instanceof ParameterizedType parameterizedType && parameterizedType.getRawType() == ResponseEntity.class) {
final String contentType = String.valueOf(response.headers().get(HttpHeaders.CONTENT_TYPE));
if (contentType.equals(OCTET_STREAM) || contentType.equals(OCTET_STREAM_UTF8)) {
final byte[] bodyData = Util.toByteArray(response.body().asInputStream());
final InputStream convertedInputStream = response.toBuilder().body(bodyData).build().body().asInputStream();
if (parameterizedType.getActualTypeArguments()[0] instanceof Class<?> clazz
&& InputStreamResource.class.isAssignableFrom(clazz)) {
return new ResponseEntity<>(new InputStreamResource(convertedInputStream), HttpStatus.valueOf(response.status()));
} else {
return new ResponseEntity<>(convertedInputStream, HttpStatus.valueOf(response.status()));
}
} else if (contentType.equals(TEXT_PLAIN) || contentType.equals(TEXT_PLAIN_UTF8)) {
final byte[] bodyData = Util.toByteArray(response.body().asInputStream());
return new ResponseEntity<>(new String(bodyData), HttpStatus.valueOf(response.status()));
}
}
return delegate.decode(response, type);
}
private static @NonNull Reader markSupportedReader(final Reader reader) {
return reader.markSupported() ? reader : new BufferedReader(reader, 1);
}
};
public static final Contract DEFAULT_CONTRACT = new SpringMvcContract();
private static final ErrorDecoder DEFAULT_ERROR_DECODER_0 = new ErrorDecoder.Default();
public static final ErrorDecoder DEFAULT_ERROR_DECODER = (methodKey, response) -> {
final Exception e = DEFAULT_ERROR_DECODER_0.decode(methodKey, response);
log.trace("REST API call failed!", e);
return e;
};
}

View File

@@ -1,5 +1,5 @@
/**
* Copyright (c) 2023 Bosch.IO GmbH and others
* Copyright (c) 2026 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
@@ -9,30 +9,13 @@
*/
package org.eclipse.hawkbit.sdk;
import java.lang.annotation.Annotation;
import java.lang.reflect.Method;
import java.util.LinkedHashMap;
import feign.Contract;
import feign.MethodMetadata;
import feign.RequestInterceptor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnNotWebApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.cloud.openfeign.FeignClientsConfiguration;
import org.springframework.cloud.openfeign.hateoas.WebConvertersCustomizer;
import org.springframework.cloud.openfeign.support.HttpMessageConverterCustomizer;
import org.springframework.cloud.openfeign.support.SpringMvcContract;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.context.annotation.Primary;
import org.springframework.context.annotation.PropertySource;
import org.springframework.hateoas.config.EnableHypermediaSupport;
import org.springframework.hateoas.config.WebConverters;
import org.springframework.http.MediaType;
@Slf4j
@Configuration
@@ -40,65 +23,4 @@ import org.springframework.http.MediaType;
@EnableHypermediaSupport(type = EnableHypermediaSupport.HypermediaType.HAL)
@Import(FeignClientsConfiguration.class)
@PropertySource("classpath:/hawkbit-sdk-defaults.properties")
public class HawkbitSDKConfiguration {
/**
* An feign request interceptor to set the defined {@code Accept} and {@code Content-Type} headers for each request
* to {@code application/json}.
*
* TODO - is this needed?
*/
@Bean
@Primary
public RequestInterceptor jsonHeaderInterceptorOverride() {
return template -> template
.header("Accept", MediaType.APPLICATION_JSON_VALUE)
.header("Content-Type", MediaType.APPLICATION_JSON_VALUE);
}
// takes place only when spring app is started in non-web-app mode
// in that case org.springframework.cloud.openfeign.hateoas.FeignHalAutoConfiguration
// is explicitly disabled and HAL/HATEOAS support doesn't work
@Bean
@ConditionalOnMissingBean
@ConditionalOnNotWebApplication
@ConditionalOnClass({ WebConverters.class })
public HttpMessageConverterCustomizer webConvertersCustomizerOverrider(WebConverters webConverters) {
return new WebConvertersCustomizer(webConverters);
}
// another option would be something like (need to import io.github.openfeign:feign-jackson
// @Bean @Primary @ConditionalOnNotWebApplication
// public Decoder feignDecoderOverride() {
// return new ResponseEntityDecoder(new JacksonDecoder(new ObjectMapper().registerModule(new Jackson2HalModule())));
// }
/**
* Own implementation of the {@link SpringMvcContract} which catches the {@link IllegalStateException} which occurs
* due multiple produces and consumes values in the request-mapping
* annotation.https://github.com/spring-cloud/spring-cloud-netflix/issues/808
*
* TODO - is this needed?
*/
@Bean
@Primary
public Contract feignContractOverride() {
return new SpringMvcContract() {
@Override
protected void processAnnotationOnMethod(final MethodMetadata data, final Annotation methodAnnotation, final Method method) {
try {
super.processAnnotationOnMethod(data, methodAnnotation, method);
} catch (final IllegalStateException e) {
// ignore illegalstateexception here because it's thrown because of
// multiple consumers and produces, see
// https://github.com/spring-cloud/spring-cloud-netflix/issues/808
log.trace(e.getMessage(), e);
// This line from super is mandatory to avoid that access to the
// expander causes a nullpointer.
data.indexToExpander(new LinkedHashMap<>());
}
}
};
}
}
public class HawkbitSDKConfiguration {}