Repository: Sundsvallskommun/api-service-invoices
Pull request #109: UF-15910 IDATA Integration
Author: login: AndreSoderlund
Created: 2025-05-02T12:15:53Z | Merged: 2025-05-07T08:56:00Z

* Added IDATA integration, setup feign to set the proper authorization header.

## Types of changes

<!--- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->

- [ ] Bug fix
- [x] New feature
- [ ] Removed feature
- [ ] Code style update (formatting etc.)
- [ ] Refactoring (no functional changes, no api changes)
- [ ] Build related changes
- [ ] Documentation content changes
- [ ] Content/Data

## Does this PR introduce a breaking change?
- [ ] Yes (I have stepped the version number accordingly)
- [x] No
      
## Checklist:

<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
<!--- If you're unsure about any of these, don't hesitate to ask. We're here to help! -->

- [x] My code follows the code style of this project.
- [x] I have updated the documentation accordingly (if applicable).
- [x] I have added/updated tests to cover my changes (if applicable).



Diff:
diff --git a/src/main/java/se/sundsvall/invoices/integration/idata/IdataClient.java b/src/main/java/se/sundsvall/invoices/integration/idata/IdataClient.java
new file mode 100644
index 0000000..3350b0e
--- /dev/null
+++ b/src/main/java/se/sundsvall/invoices/integration/idata/IdataClient.java
@@ -0,0 +1,21 @@
+package se.sundsvall.invoices.integration.idata;
+
+import static org.springframework.http.MediaType.ALL_VALUE;
+import static se.sundsvall.invoices.integration.idata.configuration.IdataConfiguration.CLIENT_ID;
+
+import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
+import org.springframework.cloud.openfeign.FeignClient;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import se.sundsvall.invoices.integration.idata.configuration.IdataConfiguration;
+
+@FeignClient(name = CLIENT_ID, url = "${integration.idata.url}", configuration = IdataConfiguration.class)
+@CircuitBreaker(name = CLIENT_ID)
+public interface IdataClient {
+
+	@GetMapping(produces = ALL_VALUE)
+	byte[] getInvoice(
+		@RequestParam(name = "customerno") String customerNumber,
+		@RequestParam(name = "invoiceno") String invoiceNumber);
+
+}
diff --git a/src/main/java/se/sundsvall/invoices/integration/idata/configuration/AuthorizationInterceptor.java b/src/main/java/se/sundsvall/invoices/integration/idata/configuration/AuthorizationInterceptor.java
new file mode 100644
index 0000000..88db880
--- /dev/null
+++ b/src/main/java/se/sundsvall/invoices/integration/idata/configuration/AuthorizationInterceptor.java
@@ -0,0 +1,78 @@
+package se.sundsvall.invoices.integration.idata.configuration;
+
+import static java.util.Collections.emptyMap;
+import static java.util.Optional.ofNullable;
+
+import feign.RequestInterceptor;
+import feign.RequestTemplate;
+import java.util.Collection;
+import java.util.Map;
+import java.util.stream.Collectors;
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import org.apache.commons.codec.binary.Hex;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.zalando.problem.Problem;
+import org.zalando.problem.Status;
+
+/**
+ * Interceptor for adding authorization headers to requests made to the IDATA API. This interceptor generates an HMAC
+ * signature using the given request parameters and secret key.
+ */
+public class AuthorizationInterceptor implements RequestInterceptor {
+
+	private static final Logger LOGGER = LoggerFactory.getLogger(AuthorizationInterceptor.class);
+	private static final String AUTHORIZATION_HEADER = "Authorization";
+	private final String apiKey;
+	private final String secretKey;
+
+	public AuthorizationInterceptor(final String apiKey, final String secretKey) {
+		this.apiKey = apiKey;
+		this.secretKey = secretKey;
+	}
+
+	@Override
+	public void apply(RequestTemplate template) {
+		var message = createMessageWithParameters(template.queries());
+
+		var signature = generateHMAC(message, secretKey);
+
+		template.header(AUTHORIZATION_HEADER, "IDATA " + apiKey + ":" + signature);
+	}
+
+	/**
+	 * Helper method that extracts the query parameters from the request template and creates a message string. This string
+	 * is used to generate the HMAC signature.
+	 *
+	 * @param  queries the query parameters from the request template
+	 * @return         the message string
+	 */
+	String createMessageWithParameters(final Map<String, Collection<String>> queries) {
+		return ofNullable(queries).orElse(emptyMap()).entrySet().stream()
+			.sorted(Map.Entry.comparingByKey())
+			.map(entry -> entry.getKey() + "=" + String.join(",", entry.getValue()))
+			.collect(Collectors.joining("&"));
+	}
+
+	/**
+	 * Generates an HMAC signature using the provided message and key.
+	 *
+	 * @param  message the message to be signed
+	 * @param  key     the secret key used for signing
+	 * @return         the HMAC signature as a hexadecimal string
+	 */
+	String generateHMAC(final String message, final String key) {
+		try {
+			var secretKeySpec = new SecretKeySpec(key.getBytes(), "HmacSHA1");
+			var mac = Mac.getInstance("HmacSHA1");
+			mac.init(secretKeySpec);
+
+			return Hex.encodeHexString(mac.doFinal(message.getBytes()));
+		} catch (Exception e) {
+			LOGGER.error("Error generating HMAC: {}", e.getMessage());
+			throw Problem.valueOf(Status.INTERNAL_SERVER_ERROR, "Error occurred when trying to generate authorization header");
+		}
+	}
+
+}
diff --git a/src/main/java/se/sundsvall/invoices/integration/idata/configuration/IdataConfiguration.java b/src/main/java/se/sundsvall/invoices/integration/idata/configuration/IdataConfiguration.java
new file mode 100644
index 0000000..1557bc4
--- /dev/null
+++ b/src/main/java/se/sundsvall/invoices/integration/idata/configuration/IdataConfiguration.java
@@ -0,0 +1,24 @@
+package se.sundsvall.invoices.integration.idata.configuration;
+
+import org.springframework.cloud.openfeign.FeignBuilderCustomizer;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Import;
+import se.sundsvall.dept44.configuration.feign.FeignConfiguration;
+import se.sundsvall.dept44.configuration.feign.FeignMultiCustomizer;
+import se.sundsvall.dept44.configuration.feign.decoder.ProblemErrorDecoder;
+
+@Import(FeignConfiguration.class)
+public class IdataConfiguration {
+
+	public static final String CLIENT_ID = "idata";
+
+	@Bean
+	FeignBuilderCustomizer feignBuilderCustomizer(final IdataProperties properties) {
+		return FeignMultiCustomizer.create()
+			.withErrorDecoder(new ProblemErrorDecoder(CLIENT_ID))
+			.withRequestTimeoutsInSeconds(properties.connectTimeout(), properties.readTimeout())
+			.withRequestInterceptor(requestTemplate -> new AuthorizationInterceptor(properties.apiKey(), properties.secretKey()).apply(requestTemplate))
+			.composeCustomizersToOne();
+	}
+
+}
diff --git a/src/main/java/se/sundsvall/invoices/integration/idata/configuration/IdataProperties.java b/src/main/java/se/sundsvall/invoices/integration/idata/configuration/IdataProperties.java
new file mode 100644
index 0000000..5b8213a
--- /dev/null
+++ b/src/main/java/se/sundsvall/invoices/integration/idata/configuration/IdataProperties.java
@@ -0,0 +1,12 @@
+package se.sundsvall.invoices.integration.idata.configuration;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+@ConfigurationProperties("integration.idata")
+public record IdataProperties(
+	String apiKey,
+	String secretKey,
+	String customerNumber,
+	int connectTimeout,
+	int readTimeout) {
+}
diff --git a/src/main/resources/application-it.yml b/src/main/resources/application-it.yml
index f6c92a0..8cc0fd3 100644
--- a/src/main/resources/application-it.yml
+++ b/src/main/resources/application-it.yml
@@ -3,6 +3,8 @@ integration:
     url: http://localhost:${wiremock.server.port:}/api-datawarehousereader
   invoicecache:
     url: http://localhost:${wiremock.server.port:}/api-invoicecache
+  idata:
+    url: http://localhost:${wiremock.server.port:}/api-idata
 spring:
   security:
     oauth2:
diff --git a/src/main/resources/application-junit.yml b/src/main/resources/application-junit.yml
index e82df5c..d34d3cc 100644
--- a/src/main/resources/application-junit.yml
+++ b/src/main/resources/application-junit.yml
@@ -3,6 +3,11 @@ integration:
     url: http://dataWarehouseReaderUrl
   invoicecache:
     url: http://invoiceCacheUrl
+  idata:
+    url: http://idataUrl
+    api-key: apiKey
+    secret-key: secretKey
+    customer-number: customerNumber
 spring:
   security:
     oauth2:
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index f5a9deb..f635bac 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -5,11 +5,14 @@ integration:
   invoicecache:
     connectTimeout: 10
     readTimeout: 20
+  idata:
+    connectTimeout: 10
+    readTimeout: 20
 logbook:
   body-filters:
     json-path:
-     - key: "['file','content']"
-       value: '[base64]'
+      - key: "['file','content']"
+        value: '[base64]'
 
 logging:
   level:
diff --git a/src/test/java/se/sundsvall/invoices/integration/datawarehousereader/configuration/DataWarehouseReaderConfigurationTest.java b/src/test/java/se/sundsvall/invoices/integration/datawarehousereader/configuration/DataWarehouseReaderConfigurationTest.java
index 0b82a1a..6da73ce 100644
--- a/src/test/java/se/sundsvall/invoices/integration/datawarehousereader/configuration/DataWarehouseReaderConfigurationTest.java
+++ b/src/test/java/se/sundsvall/invoices/integration/datawarehousereader/configuration/DataWarehouseReaderConfigurationTest.java
@@ -54,11 +54,11 @@ void testFeignBuilderHelper() {
 
 		// Mock static FeignMultiCustomizer to enable spy and to verify that static method is being called
 		try (MockedStatic<FeignMultiCustomizer> feignMultiCustomizerMock = Mockito.mockStatic(FeignMultiCustomizer.class)) {
-			feignMultiCustomizerMock.when(() -> FeignMultiCustomizer.create()).thenReturn(feignMultiCustomizerSpy);
+			feignMultiCustomizerMock.when(FeignMultiCustomizer::create).thenReturn(feignMultiCustomizerSpy);
 
 			configuration.feignBuilderCustomizer(clientRepositoryMock, propertiesMock);
 
-			feignMultiCustomizerMock.verify(() -> FeignMultiCustomizer.create());
+			feignMultiCustomizerMock.verify(FeignMultiCustomizer::create);
 		}
 
 		// Verifications
diff --git a/src/test/java/se/sundsvall/invoices/integration/idata/configuration/AuthorizationInterceptorTest.java b/src/test/java/se/sundsvall/invoices/integration/idata/configuration/AuthorizationInterceptorTest.java
new file mode 100644
index 0000000..1140715
--- /dev/null
+++ b/src/test/java/se/sundsvall/invoices/integration/idata/configuration/AuthorizationInterceptorTest.java
@@ -0,0 +1,98 @@
+package se.sundsvall.invoices.integration.idata.configuration;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mockStatic;
+
+import feign.RequestTemplate;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Stream;
+import org.apache.commons.codec.binary.Hex;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.mockito.InjectMocks;
+import org.mockito.MockedStatic;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.zalando.problem.Problem;
+
+@ExtendWith(MockitoExtension.class)
+class AuthorizationInterceptorTest {
+
+	@InjectMocks
+	private AuthorizationInterceptor interceptor;
+
+	@BeforeEach
+	void setup() {
+		interceptor = new AuthorizationInterceptor("b1552d7b-e736-48b8-86fe-ab1dd85d7007", "secretKey");
+	}
+
+	@Test
+	void addAuthorizationHeader() {
+		var template = new RequestTemplate();
+		var requestParameters = new HashMap<String, Collection<String>>();
+		requestParameters.put("parameter1", List.of("data1"));
+		requestParameters.put("parameter2", List.of("data2"));
+		template.queries(requestParameters);
+
+		interceptor.apply(template);
+
+		assertThat(template.headers()).containsKey("Authorization");
+		var header = template.headers().get("Authorization");
+
+		assertThat(header).isEqualTo(List.of("IDATA b1552d7b-e736-48b8-86fe-ab1dd85d7007:3d9920941433af3f310fe0a2d18ae7afd83ee04d"));
+	}
+
+	@ParameterizedTest
+	@MethodSource("createMessageWithParametersArgumentsProvider")
+	void createMessageWithParameters(final String key1, final List<String> values1, final String key2, final List<String> values2, final String expected) {
+		Map<String, Collection<String>> queries = new HashMap<>();
+		Optional.ofNullable(key1).ifPresent(key -> queries.put(key, values1));
+		Optional.ofNullable(key2).ifPresent(key -> queries.put(key, values2));
+
+		var result = interceptor.createMessageWithParameters(queries);
+
+		assertThat(result).isEqualTo(expected);
+	}
+
+	private static Stream<Arguments> createMessageWithParametersArgumentsProvider() {
+		return Stream.of(
+			Arguments.of("customerno", List.of("123456"), "invoiceno", List.of("654321"), "customerno=123456&invoiceno=654321"),
+			Arguments.of("customerno", List.of("123456", "321321"), "invoiceno", List.of("654321", "213456"), "customerno=123456,321321&invoiceno=654321,213456"),
+			Arguments.of(null, null, null, null, ""));
+	}
+
+	@ParameterizedTest
+	@MethodSource("generateHMACArgumentsProvider")
+	void generateHMAC(final String message, final String key, final String expected) {
+		var result = interceptor.generateHMAC(message, key);
+
+		assertThat(result).isEqualTo(expected);
+	}
+
+	@Test
+	void generateHMAC_throws() {
+		try (MockedStatic<Hex> mockedHex = mockStatic(Hex.class)) {
+			mockedHex.when(() -> Hex.encodeHexString((byte[]) any())).thenThrow(new RuntimeException("Mocked exception"));
+
+			assertThatThrownBy(() -> interceptor.generateHMAC("message", "key"))
+				.isInstanceOf(Problem.class)
+				.hasMessageContaining("Error occurred when trying to generate authorization header");
+		}
+	}
+
+	private static Stream<Arguments> generateHMACArgumentsProvider() {
+		return Stream.of(
+			Arguments.of("customerno=123456&invoiceno=654321", "SecretKey", "c4224db74f128debe70deec74fcfbbd12007427d"),
+			Arguments.of("customerno=123456&invoiceno=123456", "SecretKey", "9f6498e108f0f59695b54a15f99ac5d237af9678"));
+
+	}
+}
diff --git a/src/test/java/se/sundsvall/invoices/integration/idata/configuration/IdataConfigurationTest.java b/src/test/java/se/sundsvall/invoices/integration/idata/configuration/IdataConfigurationTest.java
new file mode 100644
index 0000000..76fd34d
--- /dev/null
+++ b/src/test/java/se/sundsvall/invoices/integration/idata/configuration/IdataConfigurationTest.java
@@ -0,0 +1,70 @@
+package se.sundsvall.invoices.integration.idata.configuration;
+
+import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static se.sundsvall.invoices.integration.idata.configuration.IdataConfiguration.CLIENT_ID;
+
+import feign.RequestInterceptor;
+import feign.codec.ErrorDecoder;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+import org.mockito.Spy;
+import org.mockito.junit.jupiter.MockitoExtension;
+import se.sundsvall.dept44.configuration.feign.FeignMultiCustomizer;
+import se.sundsvall.dept44.configuration.feign.decoder.ProblemErrorDecoder;
+
+@ExtendWith(MockitoExtension.class)
+class IdataConfigurationTest {
+
+	@Mock
+	private IdataProperties propertiesMock;
+
+	@Spy
+	private FeignMultiCustomizer feignMultiCustomizerSpy;
+
+	@Captor
+	private ArgumentCaptor<ErrorDecoder> errorDecoderCaptor;
+
+	@InjectMocks
+	private IdataConfiguration configuration;
+
+	@Test
+	void testFeignBuilderHelper() {
+		final var connectTimeout = 123;
+		final var readTimeout = 321;
+
+		when(propertiesMock.connectTimeout()).thenReturn(connectTimeout);
+		when(propertiesMock.readTimeout()).thenReturn(readTimeout);
+
+		// Mock static FeignMultiCustomizer to enable spy and to verify that static method is being called
+		try (MockedStatic<FeignMultiCustomizer> feignMultiCustomizerMock = Mockito.mockStatic(FeignMultiCustomizer.class)) {
+			feignMultiCustomizerMock.when(FeignMultiCustomizer::create).thenReturn(feignMultiCustomizerSpy);
+
+			configuration.feignBuilderCustomizer(propertiesMock);
+
+			feignMultiCustomizerMock.verify(FeignMultiCustomizer::create);
+		}
+
+		// Verifications
+		verify(propertiesMock).connectTimeout();
+		verify(propertiesMock).readTimeout();
+
+		verify(feignMultiCustomizerSpy).withErrorDecoder(errorDecoderCaptor.capture());
+		verify(feignMultiCustomizerSpy).withRequestTimeoutsInSeconds(connectTimeout, readTimeout);
+		verify(feignMultiCustomizerSpy).withRequestInterceptor(any(RequestInterceptor.class));
+		verify(feignMultiCustomizerSpy).composeCustomizersToOne();
+
+		// Assert ErrorDecoder
+		assertThat(errorDecoderCaptor.getValue())
+			.isInstanceOf(ProblemErrorDecoder.class)
+			.hasFieldOrPropertyWithValue("integrationName", CLIENT_ID);
+	}
+}
diff --git a/src/test/java/se/sundsvall/invoices/integration/idata/configuration/IdataPropertiesTest.java b/src/test/java/se/sundsvall/invoices/integration/idata/configuration/IdataPropertiesTest.java
new file mode 100644
index 0000000..902f357
--- /dev/null
+++ b/src/test/java/se/sundsvall/invoices/integration/idata/configuration/IdataPropertiesTest.java
@@ -0,0 +1,26 @@
+package se.sundsvall.invoices.integration.idata.configuration;
+
+import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.ActiveProfiles;
+import se.sundsvall.invoices.Application;
+
+@SpringBootTest(classes = Application.class)
+@ActiveProfiles("junit")
+class IdataPropertiesTest {
+
+	@Autowired
+	private IdataProperties properties;
+
+	@Test
+	void testProperties() {
+		assertThat(properties.connectTimeout()).isEqualTo(10);
+		assertThat(properties.readTimeout()).isEqualTo(20);
+		assertThat(properties.apiKey()).isEqualTo("apiKey");
+		assertThat(properties.secretKey()).isEqualTo("secretKey");
+		assertThat(properties.customerNumber()).isEqualTo("customerNumber");
+	}
+}


Reviews:
user: login: josc04
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T06:24:38Z

Review comments:
diff_hunk: @@ -0,0 +1,75 @@
+package se.sundsvall.invoices.integration.idata.configuration;
+
+import feign.RequestInterceptor;
+import feign.RequestTemplate;
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+import java.util.Collection;
+import java.util.Map;
+import java.util.stream.Collectors;
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import org.apache.commons.codec.binary.Hex;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Interceptor for adding authorization headers to requests made to the IDATA API. This interceptor generates an HMAC
+ * signature using the given request parameters and secret key.
+ */
+public class AuthorizationInterceptor implements RequestInterceptor {
+
+	private static final Logger LOGGER = LoggerFactory.getLogger(AuthorizationInterceptor.class);
+	private static final String AUTHORIZATION_HEADER = "Authorization";
+	private final String apiKey;
+	private final String secretKey;
+
+	public AuthorizationInterceptor(final String apiKey, final String secretKey) {
+		this.apiKey = apiKey;
+		this.secretKey = secretKey;
+	}
+
+	@Override
+	public void apply(RequestTemplate template) {
+		var message = createMessageWithParameters(template.queries());
+
+		var signature = generateHMAC(message, secretKey);
+
+		template.header(AUTHORIZATION_HEADER, "IDATA " + apiKey + ":" + signature);
+	}
+
+	/**
+	 * Helper method that extracts the query parameters from the request template and creates a message string. This string
+	 * is used to generate the HMAC signature.
+	 *
+	 * @param  queries the query parameters from the request template
+	 * @return         the message string
+	 */
+	String createMessageWithParameters(final Map<String, Collection<String>> queries) {
+		return queries.entrySet().stream()
+			.sorted(Map.Entry.comparingByKey())
+			.map(entry -> entry.getKey() + "=" + String.join(",", entry.getValue()))
+			.collect(Collectors.joining("&"));
+	}
+
+	/**
+	 * Generates an HMAC signature using the provided message and key.
+	 *
+	 * @param  message the message to be signed
+	 * @param  key     the secret key used for signing
+	 * @return         the HMAC signature as a hexadecimal string
+	 */
+	String generateHMAC(final String message, final String key) {
+		try {
+			var secretKeySpec = new SecretKeySpec(key.getBytes(), "HmacSHA1");
+			var mac = Mac.getInstance("HmacSHA1");
+			mac.init(secretKeySpec);
+
+			return Hex.encodeHexString(mac.doFinal(message.getBytes()));
+		} catch (NoSuchAlgorithmException | InvalidKeyException e) {
+			LOGGER.error("Error generating HMAC: {}", e.getMessage());
+			return null;
path: src/main/java/se/sundsvall/invoices/integration/idata/configuration/AuthorizationInterceptor.java
user: login: josc04
body: Should this method return null or throw Problem.valueOf(500)...? In the end the result will be the same I guess, but with different error codes. 

- If null is returned, a 401 or 403 is probably returned from ICASE, resulting in a 502 - Bad Gateway from this service, which is somewhat difficult to interpret into a failure to generate the auth header without some digging
- If we throw an explicit 500 exception with a proper error text (i.e. something like "error occured when trying to generate authorization header") the service will return 500 - Internal Error from this service with an explicit message of what went wrong

created_at: 2025-05-05T06:00:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,21 @@
+package se.sundsvall.invoices.integration.idata;
+
+import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE;
+import static se.sundsvall.invoices.integration.idata.configuration.IdataConfiguration.CLIENT_ID;
+
+import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
+import org.springframework.cloud.openfeign.FeignClient;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import se.sundsvall.invoices.integration.idata.configuration.IdataConfiguration;
+
+@FeignClient(name = CLIENT_ID, url = "${integration.idata.url}", configuration = IdataConfiguration.class)
+@CircuitBreaker(name = CLIENT_ID)
+public interface IdataClient {
+
+	@GetMapping(produces = APPLICATION_JSON_VALUE)
path: src/main/java/se/sundsvall/invoices/integration/idata/IdataClient.java
user: login: josc04
body: APPLICATION_PDF_VALUE or ALL_VALUE (I guess that it is a byte array containing a pdf file that is returned and not json)?
created_at: 2025-05-05T06:21:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,75 @@
+package se.sundsvall.invoices.integration.idata.configuration;
+
+import feign.RequestInterceptor;
+import feign.RequestTemplate;
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+import java.util.Collection;
+import java.util.Map;
+import java.util.stream.Collectors;
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import org.apache.commons.codec.binary.Hex;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Interceptor for adding authorization headers to requests made to the IDATA API. This interceptor generates an HMAC
+ * signature using the given request parameters and secret key.
+ */
+public class AuthorizationInterceptor implements RequestInterceptor {
+
+	private static final Logger LOGGER = LoggerFactory.getLogger(AuthorizationInterceptor.class);
+	private static final String AUTHORIZATION_HEADER = "Authorization";
+	private final String apiKey;
+	private final String secretKey;
+
+	public AuthorizationInterceptor(final String apiKey, final String secretKey) {
+		this.apiKey = apiKey;
+		this.secretKey = secretKey;
+	}
+
+	@Override
+	public void apply(RequestTemplate template) {
+		var message = createMessageWithParameters(template.queries());
+
+		var signature = generateHMAC(message, secretKey);
+
+		template.header(AUTHORIZATION_HEADER, "IDATA " + apiKey + ":" + signature);
+	}
+
+	/**
+	 * Helper method that extracts the query parameters from the request template and creates a message string. This string
+	 * is used to generate the HMAC signature.
+	 *
+	 * @param  queries the query parameters from the request template
+	 * @return         the message string
+	 */
+	String createMessageWithParameters(final Map<String, Collection<String>> queries) {
+		return queries.entrySet().stream()
path: src/main/java/se/sundsvall/invoices/integration/idata/configuration/AuthorizationInterceptor.java
user: login: josc04
body: Adjust for possible null value in incoming parameter?
created_at: 2025-05-05T06:22:59Z
author_association: CONTRIBUTOR

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-05-07T08:39:30Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=Sundsvallskommun_api-service-invoices&pullRequest=109) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=Sundsvallskommun_api-service-invoices&pullRequest=109&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=Sundsvallskommun_api-service-invoices&pullRequest=109&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=Sundsvallskommun_api-service-invoices&pullRequest=109&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=Sundsvallskommun_api-service-invoices&pullRequest=109&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=Sundsvallskommun_api-service-invoices&pullRequest=109&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=Sundsvallskommun_api-service-invoices&pullRequest=109)


author_association: NONE