createResponseHandler() {
@@ -125,6 +132,30 @@ protected boolean handleResponse(ClassicHttpResponse response) {
return false;
}
+
+ /**
+ * Applies headers from all registered providers to the given GET request.
+ *
+ * Since GET requests generally do not carry a body, this method initializes
+ * the {@link TrolieRequestContext} with a {@code null} content type, allowing
+ * providers to apply relevant headers based on the request metadata.
+ *
+ * @param request the HTTP GET request to which headers will be applied
+ * @throws URISyntaxException if the request URI cannot be parsed
+ */
+ protected void applyRequestHeaderProviders(HttpGet request) throws URISyntaxException {
+
+ TrolieRequestContext context = new TrolieRequestContext(request.getMethod(),
+ request.getUri(),
+ null);
+ var mergedHeaders = new LinkedHashMap();
+
+ for (var provider : providers) {
+ mergedHeaders.putAll(provider.headersFor(context));
+ }
+
+ mergedHeaders.forEach(request::setHeader);
+ }
protected HttpGet createRequest() throws URISyntaxException {
HttpGet get = new HttpGet(getFullPath());
@@ -132,7 +163,7 @@ protected HttpGet createRequest() throws URISyntaxException {
if (httpHeaders != null && !httpHeaders.isEmpty()) {
httpHeaders.forEach(get::addHeader);
}
-
+
get.setConfig(requestConfig);
return get;
}
@@ -141,6 +172,9 @@ public void executeRequest() {
try {
lastRequestFailed = false;
HttpGet get = createRequest();
+ if (providers != null && !providers.isEmpty()) {
+ applyRequestHeaderProviders(get);
+ }
httpClient.execute(host.getHost(), get, createResponseHandler());
} catch (IOException e) {
diff --git a/java-client/src/main/java/energy/trolie/client/impl/request/AbstractStreamingSubscribedGet.java b/java-client/src/main/java/energy/trolie/client/impl/request/AbstractStreamingSubscribedGet.java
index 40e8d6f..0553973 100644
--- a/java-client/src/main/java/energy/trolie/client/impl/request/AbstractStreamingSubscribedGet.java
+++ b/java-client/src/main/java/energy/trolie/client/impl/request/AbstractStreamingSubscribedGet.java
@@ -2,6 +2,7 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import energy.trolie.client.ETagStore;
+import energy.trolie.client.RequestHeaderProvider;
import energy.trolie.client.StreamingSubscribedResponseReceiver;
import energy.trolie.client.TrolieHost;
import energy.trolie.client.exception.StreamingGetHandlingException;
@@ -16,6 +17,7 @@
import org.slf4j.LoggerFactory;
import java.net.URISyntaxException;
+import java.util.List;
import java.util.Map;
import java.util.concurrent.Callable;
import java.util.concurrent.Future;
@@ -49,10 +51,11 @@ protected AbstractStreamingSubscribedGet(
int bufferSize,
ObjectMapper objectMapper,
Map httpHeaders,
+ List providers,
int pollingRateMillis,
T receiver,
ETagStore eTagStore) {
- super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, receiver);
+ super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, providers, receiver);
this.pollingRateMillis = pollingRateMillis;
this.eTagStore = eTagStore;
}
diff --git a/java-client/src/main/java/energy/trolie/client/impl/request/AbstractStreamingUpdate.java b/java-client/src/main/java/energy/trolie/client/impl/request/AbstractStreamingUpdate.java
index 9c1acda..672a9a4 100644
--- a/java-client/src/main/java/energy/trolie/client/impl/request/AbstractStreamingUpdate.java
+++ b/java-client/src/main/java/energy/trolie/client/impl/request/AbstractStreamingUpdate.java
@@ -1,8 +1,10 @@
package energy.trolie.client.impl.request;
import com.fasterxml.jackson.databind.ObjectMapper;
+import energy.trolie.client.RequestHeaderProvider;
import energy.trolie.client.StreamingUpdate;
import energy.trolie.client.TrolieHost;
+import energy.trolie.client.TrolieRequestContext;
import energy.trolie.client.exception.TrolieException;
import energy.trolie.client.exception.TrolieServerException;
import lombok.AllArgsConstructor;
@@ -13,6 +15,7 @@
import org.apache.hc.client5.http.entity.GzipCompressingEntity;
import org.apache.hc.client5.http.impl.classic.AbstractHttpClientResponseHandler;
import org.apache.hc.core5.http.ContentType;
+import org.apache.hc.core5.http.Header;
import org.apache.hc.core5.http.HttpEntity;
import org.apache.hc.core5.http.HttpHeaders;
import org.apache.hc.core5.http.io.entity.InputStreamEntity;
@@ -23,6 +26,9 @@
import java.io.OutputStream;
import java.io.PipedInputStream;
import java.io.PipedOutputStream;
+import java.net.URISyntaxException;
+import java.util.LinkedHashMap;
+import java.util.List;
import java.util.Map;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
@@ -50,9 +56,10 @@ public abstract class AbstractStreamingUpdate implements StreamingUpdate {
Future responseFuture;
Map httpHeaders;
+ List providers;
protected AbstractStreamingUpdate(HttpClient httpClient, TrolieHost host, RequestConfig requestConfig,
- int bufferSize, ObjectMapper objectMapper, Map httpHeaders) {
+ int bufferSize, ObjectMapper objectMapper, Map httpHeaders, List providers) {
super();
this.httpClient = httpClient;
this.host = host;
@@ -62,6 +69,7 @@ protected AbstractStreamingUpdate(HttpClient httpClient, TrolieHost host, Reques
this.bufferSize = bufferSize;
this.objectMapper = objectMapper;
this.httpHeaders = httpHeaders;
+ this.providers = providers;
}
/**
@@ -122,9 +130,9 @@ protected void handleWriteError(Exception e) {
* initiate the request and pipe an output stream to the request entity
*
* @return
- * @throws IOException
+ * @throws IOException URISyntaxException
*/
- protected OutputStream createRequestOutputStream() throws IOException {
+ protected OutputStream createRequestOutputStream() throws IOException, URISyntaxException {
//they probably already set these parameters, but may as well make sure
HttpUriRequestBase request = getRequest();
@@ -136,6 +144,10 @@ protected OutputStream createRequestOutputStream() throws IOException {
request.setConfig(this.requestConfig);
+ if (providers != null && !providers.isEmpty()) {
+ applyRequestHeaderProviders(request);
+ }
+
//create a request entity we can write into from a stream
PipedOutputStream pipedOutputStream = new PipedOutputStream();
PipedInputStream pipedInputStream = new PipedInputStream(pipedOutputStream, bufferSize);
@@ -152,6 +164,52 @@ protected OutputStream createRequestOutputStream() throws IOException {
return outputStream;
}
+ /**
+ * Determines the Content-Type for the request context.
+ *
+ * This method prioritizes any existing Content-Type header already set on the request.
+ * If none is found, it falls back to the {@link #getContentType()} provided by this instance.
+ *
+ * @param request the current HTTP request
+ * @return the Content-Type as a string, or {@code null} if none is defined
+ */
+ private String determineContentType(HttpUriRequestBase request) {
+ Header header = request.getFirstHeader(HttpHeaders.CONTENT_TYPE);
+ if (header != null) {
+ return header.getValue();
+ }
+
+ return getContentType() != null ? getContentType().toString() : null;
+ }
+
+ /**
+ * Applies headers from all registered providers to the given request.
+ *
+ * This method builds a {@link TrolieRequestContext} based on the request's current state,
+ * allowing providers to inspect the request and contribute necessary headers.
+ * The resulting headers are then merged and applied to the request instance.
+ *
+ * @param request the HTTP request to which headers will be applied
+ * @throws URISyntaxException if the request URI cannot be parsed
+ */
+ protected void applyRequestHeaderProviders(HttpUriRequestBase request) throws URISyntaxException {
+ String contentType = determineContentType(request);
+
+ var context = new TrolieRequestContext(
+ request.getMethod(),
+ request.getUri(),
+ contentType
+ );
+
+ var mergedHeaders = new LinkedHashMap();
+
+ for (var provider : providers) {
+ mergedHeaders.putAll(provider.headersFor(context));
+ }
+
+ mergedHeaders.forEach(request::setHeader);
+ }
+
/**
* Finalize the stream and check for errors on the request thread
*
diff --git a/java-client/src/main/java/energy/trolie/client/impl/request/monitoringsets/DefaultMonitoringSetRequest.java b/java-client/src/main/java/energy/trolie/client/impl/request/monitoringsets/DefaultMonitoringSetRequest.java
index d7757bd..f2c174e 100644
--- a/java-client/src/main/java/energy/trolie/client/impl/request/monitoringsets/DefaultMonitoringSetRequest.java
+++ b/java-client/src/main/java/energy/trolie/client/impl/request/monitoringsets/DefaultMonitoringSetRequest.java
@@ -1,6 +1,7 @@
package energy.trolie.client.impl.request.monitoringsets;
import com.fasterxml.jackson.databind.ObjectMapper;
+import energy.trolie.client.RequestHeaderProvider;
import energy.trolie.client.TrolieApiConstants;
import energy.trolie.client.TrolieHost;
import energy.trolie.client.impl.request.AbstractStreamingGet;
@@ -9,6 +10,7 @@
import org.apache.hc.client5.http.config.RequestConfig;
import java.io.InputStream;
+import java.util.List;
import java.util.Map;
/**
@@ -24,9 +26,10 @@ public DefaultMonitoringSetRequest(
int bufferSize,
ObjectMapper objectMapper,
Map httpHeaders,
+ List providers,
MonitoringSetsReceiver receiver) {
- super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, receiver);
+ super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, providers, receiver);
}
@Override
diff --git a/java-client/src/main/java/energy/trolie/client/impl/request/monitoringsets/DefaultMonitoringSetSubscribedRequest.java b/java-client/src/main/java/energy/trolie/client/impl/request/monitoringsets/DefaultMonitoringSetSubscribedRequest.java
index 081d5d7..4246e67 100644
--- a/java-client/src/main/java/energy/trolie/client/impl/request/monitoringsets/DefaultMonitoringSetSubscribedRequest.java
+++ b/java-client/src/main/java/energy/trolie/client/impl/request/monitoringsets/DefaultMonitoringSetSubscribedRequest.java
@@ -2,6 +2,7 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import energy.trolie.client.ETagStore;
+import energy.trolie.client.RequestHeaderProvider;
import energy.trolie.client.TrolieApiConstants;
import energy.trolie.client.TrolieHost;
import energy.trolie.client.impl.request.AbstractStreamingSubscribedGet;
@@ -10,6 +11,7 @@
import org.apache.hc.client5.http.config.RequestConfig;
import java.io.InputStream;
+import java.util.List;
import java.util.Map;
/**
@@ -25,10 +27,11 @@ public DefaultMonitoringSetSubscribedRequest(
int bufferSize,
ObjectMapper objectMapper,
Map httpHeaders,
+ List providers,
int pollingRateMillis,
MonitoringSetsSubscribedReceiver receiver,
ETagStore eTagStore) {
- super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, pollingRateMillis,
+ super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, providers, pollingRateMillis,
receiver, eTagStore);
}
diff --git a/java-client/src/main/java/energy/trolie/client/impl/request/monitoringsets/MonitoringSetsRequest.java b/java-client/src/main/java/energy/trolie/client/impl/request/monitoringsets/MonitoringSetsRequest.java
index 0c4b8d4..7318fda 100644
--- a/java-client/src/main/java/energy/trolie/client/impl/request/monitoringsets/MonitoringSetsRequest.java
+++ b/java-client/src/main/java/energy/trolie/client/impl/request/monitoringsets/MonitoringSetsRequest.java
@@ -1,6 +1,7 @@
package energy.trolie.client.impl.request.monitoringsets;
import com.fasterxml.jackson.databind.ObjectMapper;
+import energy.trolie.client.RequestHeaderProvider;
import energy.trolie.client.TrolieApiConstants;
import energy.trolie.client.TrolieHost;
import energy.trolie.client.impl.request.AbstractStreamingGet;
@@ -12,6 +13,7 @@
import java.io.InputStream;
import java.net.URISyntaxException;
+import java.util.List;
import java.util.Map;
/**
@@ -28,10 +30,11 @@ public MonitoringSetsRequest(
int bufferSize,
ObjectMapper objectMapper,
Map httpHeaders,
+ List providers,
MonitoringSetsReceiver receiver,
String monitoringSet) {
- super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, receiver);
+ super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, providers, receiver);
if (monitoringSet == null || monitoringSet.isBlank()) {
throw new IllegalArgumentException("Monitoring set name cannot be null or blank");
diff --git a/java-client/src/main/java/energy/trolie/client/impl/request/monitoringsets/MonitoringSetsSubscribedRequest.java b/java-client/src/main/java/energy/trolie/client/impl/request/monitoringsets/MonitoringSetsSubscribedRequest.java
index 9a165b0..02178f7 100644
--- a/java-client/src/main/java/energy/trolie/client/impl/request/monitoringsets/MonitoringSetsSubscribedRequest.java
+++ b/java-client/src/main/java/energy/trolie/client/impl/request/monitoringsets/MonitoringSetsSubscribedRequest.java
@@ -2,6 +2,7 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import energy.trolie.client.ETagStore;
+import energy.trolie.client.RequestHeaderProvider;
import energy.trolie.client.TrolieApiConstants;
import energy.trolie.client.TrolieHost;
import energy.trolie.client.impl.request.AbstractStreamingSubscribedGet;
@@ -13,6 +14,7 @@
import java.io.InputStream;
import java.net.URISyntaxException;
+import java.util.List;
import java.util.Map;
/**
@@ -29,12 +31,13 @@ public MonitoringSetsSubscribedRequest(
int bufferSize,
ObjectMapper objectMapper,
Map httpHeaders,
+ List providers,
int pollingRateMillis,
MonitoringSetsSubscribedReceiver receiver,
ETagStore eTagStore,
String monitoringSet) {
- super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, pollingRateMillis,
+ super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, providers, pollingRateMillis,
receiver, eTagStore);
if (monitoringSet == null || monitoringSet.isBlank()) {
diff --git a/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/ForecastSnapshotRequest.java b/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/ForecastSnapshotRequest.java
index 2869650..5aac506 100644
--- a/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/ForecastSnapshotRequest.java
+++ b/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/ForecastSnapshotRequest.java
@@ -1,6 +1,7 @@
package energy.trolie.client.impl.request.operatingsnapshots;
import com.fasterxml.jackson.databind.ObjectMapper;
+import energy.trolie.client.RequestHeaderProvider;
import energy.trolie.client.TrolieApiConstants;
import energy.trolie.client.TrolieHost;
import energy.trolie.client.impl.request.AbstractStreamingGet;
@@ -13,6 +14,7 @@
import java.io.InputStream;
import java.net.URISyntaxException;
import java.time.Instant;
+import java.util.List;
import java.util.Map;
/**
@@ -32,13 +34,14 @@ public ForecastSnapshotRequest(
int bufferSize,
ObjectMapper objectMapper,
Map httpHeaders,
+ List providers,
ForecastSnapshotReceiver receiver,
String monitoringSet,
String resourceId,
Instant offsetPeriodStart,
Instant periodEnd) {
- super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, receiver);
+ super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, providers, receiver);
this.monitoringSet = monitoringSet;
this.resourceId = resourceId;
this.offsetPeriodStart = offsetPeriodStart;
diff --git a/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/ForecastSnapshotSubscribedRequest.java b/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/ForecastSnapshotSubscribedRequest.java
index 1fa2749..0e99038 100644
--- a/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/ForecastSnapshotSubscribedRequest.java
+++ b/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/ForecastSnapshotSubscribedRequest.java
@@ -2,6 +2,7 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import energy.trolie.client.ETagStore;
+import energy.trolie.client.RequestHeaderProvider;
import energy.trolie.client.TrolieApiConstants;
import energy.trolie.client.TrolieHost;
import energy.trolie.client.impl.request.AbstractStreamingSubscribedGet;
@@ -13,6 +14,7 @@
import java.io.InputStream;
import java.net.URISyntaxException;
+import java.util.List;
import java.util.Map;
/**
@@ -29,12 +31,13 @@ public ForecastSnapshotSubscribedRequest(
int bufferSize,
ObjectMapper objectMapper,
Map httpHeaders,
+ List providers,
int pollingRateMillis,
ForecastSnapshotSubscribedReceiver receiver,
ETagStore eTagStore,
String monitoringSet) {
- super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, pollingRateMillis,
+ super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, providers, pollingRateMillis,
receiver, eTagStore);
this.monitoringSet = monitoringSet;
}
diff --git a/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/RealTimeSnapshotRequest.java b/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/RealTimeSnapshotRequest.java
index 23f0d0e..ccedd3b 100644
--- a/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/RealTimeSnapshotRequest.java
+++ b/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/RealTimeSnapshotRequest.java
@@ -1,6 +1,7 @@
package energy.trolie.client.impl.request.operatingsnapshots;
import com.fasterxml.jackson.databind.ObjectMapper;
+import energy.trolie.client.RequestHeaderProvider;
import energy.trolie.client.TrolieApiConstants;
import energy.trolie.client.TrolieHost;
import energy.trolie.client.impl.request.AbstractStreamingGet;
@@ -12,6 +13,7 @@
import java.io.InputStream;
import java.net.URISyntaxException;
+import java.util.List;
import java.util.Map;
/**
@@ -29,11 +31,12 @@ public RealTimeSnapshotRequest(
int bufferSize,
ObjectMapper objectMapper,
Map httpHeaders,
+ List providers,
RealTimeSnapshotReceiver receiver,
String monitoringSet,
String resourceId) {
- super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, receiver);
+ super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, providers, receiver);
this.monitoringSet = monitoringSet;
this.resourceId = resourceId;
}
diff --git a/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/RealTimeSnapshotSubscribedRequest.java b/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/RealTimeSnapshotSubscribedRequest.java
index b696737..2e62d2f 100644
--- a/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/RealTimeSnapshotSubscribedRequest.java
+++ b/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/RealTimeSnapshotSubscribedRequest.java
@@ -2,6 +2,7 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import energy.trolie.client.ETagStore;
+import energy.trolie.client.RequestHeaderProvider;
import energy.trolie.client.TrolieApiConstants;
import energy.trolie.client.TrolieHost;
import energy.trolie.client.impl.request.AbstractStreamingSubscribedGet;
@@ -13,6 +14,7 @@
import java.io.InputStream;
import java.net.URISyntaxException;
+import java.util.List;
import java.util.Map;
/**
@@ -30,13 +32,14 @@ public RealTimeSnapshotSubscribedRequest(
int bufferSize,
ObjectMapper objectMapper,
Map httpHeaders,
+ List providers,
int pollingRateMillis,
RealTimeSnapshotSubscribedReceiver receiver,
ETagStore eTagStore,
String monitoringSet,
String resourceId) {
- super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders,
+ super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, providers,
pollingRateMillis, receiver, eTagStore);
this.monitoringSet = monitoringSet;
this.resourceId = resourceId;
diff --git a/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/RegionalForecastSnapshotRequest.java b/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/RegionalForecastSnapshotRequest.java
index 2677362..6881dfd 100644
--- a/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/RegionalForecastSnapshotRequest.java
+++ b/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/RegionalForecastSnapshotRequest.java
@@ -1,6 +1,7 @@
package energy.trolie.client.impl.request.operatingsnapshots;
import com.fasterxml.jackson.databind.ObjectMapper;
+import energy.trolie.client.RequestHeaderProvider;
import energy.trolie.client.TrolieApiConstants;
import energy.trolie.client.TrolieHost;
import energy.trolie.client.request.operatingsnapshots.ForecastSnapshotReceiver;
@@ -8,6 +9,7 @@
import org.apache.hc.client5.http.config.RequestConfig;
import java.time.Instant;
+import java.util.List;
import java.util.Map;
/**
@@ -22,13 +24,14 @@ public RegionalForecastSnapshotRequest(
int bufferSize,
ObjectMapper objectMapper,
Map httpHeaders,
+ List providers,
ForecastSnapshotReceiver receiver,
String monitoringSet,
String resourceId,
Instant offsetPeriodStart,
Instant periodEnd) {
- super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, receiver,
+ super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, providers, receiver,
monitoringSet, resourceId, offsetPeriodStart, periodEnd);
}
diff --git a/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/RegionalForecastSubscribedSnapshotRequest.java b/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/RegionalForecastSubscribedSnapshotRequest.java
index c653112..4f467ed 100644
--- a/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/RegionalForecastSubscribedSnapshotRequest.java
+++ b/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/RegionalForecastSubscribedSnapshotRequest.java
@@ -2,12 +2,14 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import energy.trolie.client.ETagStore;
+import energy.trolie.client.RequestHeaderProvider;
import energy.trolie.client.TrolieApiConstants;
import energy.trolie.client.TrolieHost;
import energy.trolie.client.request.operatingsnapshots.ForecastSnapshotSubscribedReceiver;
import org.apache.hc.client5.http.classic.HttpClient;
import org.apache.hc.client5.http.config.RequestConfig;
+import java.util.List;
import java.util.Map;
/**
@@ -22,12 +24,13 @@ public RegionalForecastSubscribedSnapshotRequest(
int bufferSize,
ObjectMapper objectMapper,
Map httpHeaders,
+ List providers,
int pollingRateMillis,
ForecastSnapshotSubscribedReceiver receiver,
ETagStore eTagStore,
String monitoringSet) {
- super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, pollingRateMillis, receiver,
+ super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, providers, pollingRateMillis, receiver,
eTagStore, monitoringSet);
}
diff --git a/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/RegionalRealTimeSnapshotRequest.java b/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/RegionalRealTimeSnapshotRequest.java
index e1308c1..223b078 100644
--- a/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/RegionalRealTimeSnapshotRequest.java
+++ b/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/RegionalRealTimeSnapshotRequest.java
@@ -1,12 +1,14 @@
package energy.trolie.client.impl.request.operatingsnapshots;
import com.fasterxml.jackson.databind.ObjectMapper;
+import energy.trolie.client.RequestHeaderProvider;
import energy.trolie.client.TrolieApiConstants;
import energy.trolie.client.TrolieHost;
import energy.trolie.client.request.operatingsnapshots.RealTimeSnapshotReceiver;
import org.apache.hc.client5.http.classic.HttpClient;
import org.apache.hc.client5.http.config.RequestConfig;
+import java.util.List;
import java.util.Map;
/**
@@ -21,11 +23,12 @@ public RegionalRealTimeSnapshotRequest(
int bufferSize,
ObjectMapper objectMapper,
Map httpHeaders,
+ List providers,
RealTimeSnapshotReceiver receiver,
String monitoringSet,
String resourceId) {
- super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, receiver,
+ super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, providers, receiver,
monitoringSet, resourceId);
}
diff --git a/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/RegionalRealTimeSnapshotSubscribedRequest.java b/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/RegionalRealTimeSnapshotSubscribedRequest.java
index 03c6301..45789f5 100644
--- a/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/RegionalRealTimeSnapshotSubscribedRequest.java
+++ b/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/RegionalRealTimeSnapshotSubscribedRequest.java
@@ -2,12 +2,14 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import energy.trolie.client.ETagStore;
+import energy.trolie.client.RequestHeaderProvider;
import energy.trolie.client.TrolieApiConstants;
import energy.trolie.client.TrolieHost;
import energy.trolie.client.request.operatingsnapshots.RealTimeSnapshotSubscribedReceiver;
import org.apache.hc.client5.http.classic.HttpClient;
import org.apache.hc.client5.http.config.RequestConfig;
+import java.util.List;
import java.util.Map;
/**
@@ -22,12 +24,13 @@ public RegionalRealTimeSnapshotSubscribedRequest(
int bufferSize,
ObjectMapper objectMapper,
Map httpHeaders,
+ List providers,
int pollingRateMillis,
RealTimeSnapshotSubscribedReceiver receiver,
ETagStore eTagStore,
String monitoringSet) {
- super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, pollingRateMillis, receiver,
+ super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, providers, pollingRateMillis, receiver,
eTagStore, monitoringSet, null);
}
diff --git a/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/SeasonalSnapshotRequest.java b/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/SeasonalSnapshotRequest.java
index af51080..3942990 100644
--- a/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/SeasonalSnapshotRequest.java
+++ b/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/SeasonalSnapshotRequest.java
@@ -1,6 +1,7 @@
package energy.trolie.client.impl.request.operatingsnapshots;
import com.fasterxml.jackson.databind.ObjectMapper;
+import energy.trolie.client.RequestHeaderProvider;
import energy.trolie.client.TrolieApiConstants;
import energy.trolie.client.TrolieHost;
import energy.trolie.client.impl.request.AbstractStreamingGet;
@@ -12,6 +13,7 @@
import java.io.InputStream;
import java.net.URISyntaxException;
+import java.util.List;
import java.util.Map;
/**
@@ -29,11 +31,12 @@ public SeasonalSnapshotRequest(
int bufferSize,
ObjectMapper objectMapper,
Map httpHeaders,
+ List providers,
SeasonalSnapshotReceiver receiver, // seasonal
String monitoringSet,
String resourceId) {
- super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, receiver);
+ super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, providers, receiver);
this.monitoringSet = monitoringSet;
this.resourceId = resourceId;
}
diff --git a/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/SeasonalSnapshotSubscribedRequest.java b/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/SeasonalSnapshotSubscribedRequest.java
index c44169f..570c4d5 100644
--- a/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/SeasonalSnapshotSubscribedRequest.java
+++ b/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/SeasonalSnapshotSubscribedRequest.java
@@ -2,6 +2,7 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import energy.trolie.client.ETagStore;
+import energy.trolie.client.RequestHeaderProvider;
import energy.trolie.client.TrolieApiConstants;
import energy.trolie.client.TrolieHost;
import energy.trolie.client.impl.request.AbstractStreamingSubscribedGet;
@@ -13,6 +14,7 @@
import java.io.InputStream;
import java.net.URISyntaxException;
+import java.util.List;
import java.util.Map;
/**
@@ -29,12 +31,13 @@ public SeasonalSnapshotSubscribedRequest(
int bufferSize,
ObjectMapper objectMapper,
Map httpHeaders,
+ List providers,
int pollingRateMillis,
SeasonalSnapshotSubscribedReceiver receiver,
ETagStore eTagStore,
String monitoringSet) {
- super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, pollingRateMillis,
+ super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, providers, pollingRateMillis,
receiver, eTagStore);
this.monitoringSet = monitoringSet;
}
diff --git a/java-client/src/main/java/energy/trolie/client/request/ratingproposals/ForecastRatingProposalUpdate.java b/java-client/src/main/java/energy/trolie/client/request/ratingproposals/ForecastRatingProposalUpdate.java
index ff47de5..87299a1 100644
--- a/java-client/src/main/java/energy/trolie/client/request/ratingproposals/ForecastRatingProposalUpdate.java
+++ b/java-client/src/main/java/energy/trolie/client/request/ratingproposals/ForecastRatingProposalUpdate.java
@@ -2,6 +2,7 @@
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.ObjectMapper;
+import energy.trolie.client.RequestHeaderProvider;
import energy.trolie.client.TrolieApiConstants;
import energy.trolie.client.TrolieHost;
import energy.trolie.client.exception.TrolieException;
@@ -21,6 +22,7 @@
import org.slf4j.LoggerFactory;
import java.time.Instant;
+import java.util.List;
import java.util.Map;
import java.util.function.Function;
@@ -48,12 +50,13 @@ public class ForecastRatingProposalUpdate extends AbstractStreamingUpdate httpHeaders,
- int defaultIntervalMinutes) {
- super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders);
+ List providers, int defaultIntervalMinutes) {
+ super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, providers);
this.defaultIntervalMinutes = defaultIntervalMinutes;
}
diff --git a/java-client/src/main/java/energy/trolie/client/request/ratingproposals/RealTimeRatingProposalUpdate.java b/java-client/src/main/java/energy/trolie/client/request/ratingproposals/RealTimeRatingProposalUpdate.java
index f50d34a..3a69bfe 100644
--- a/java-client/src/main/java/energy/trolie/client/request/ratingproposals/RealTimeRatingProposalUpdate.java
+++ b/java-client/src/main/java/energy/trolie/client/request/ratingproposals/RealTimeRatingProposalUpdate.java
@@ -2,6 +2,7 @@
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.ObjectMapper;
+import energy.trolie.client.RequestHeaderProvider;
import energy.trolie.client.TrolieApiConstants;
import energy.trolie.client.TrolieHost;
import energy.trolie.client.exception.TrolieException;
@@ -18,6 +19,7 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import java.util.List;
import java.util.Map;
import java.util.function.Function;
@@ -42,10 +44,11 @@ public class RealTimeRatingProposalUpdate extends AbstractStreamingUpdate httpHeader) {
- super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeader);
+ int bufferSize, ObjectMapper objectMapper, Map httpHeader, List providers) {
+ super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeader, providers);
}
private enum Scope {
diff --git a/java-client/src/main/java/energy/trolie/client/spp/SppApiTokenHeaderProvider.java b/java-client/src/main/java/energy/trolie/client/spp/SppApiTokenHeaderProvider.java
new file mode 100644
index 0000000..36ad9d7
--- /dev/null
+++ b/java-client/src/main/java/energy/trolie/client/spp/SppApiTokenHeaderProvider.java
@@ -0,0 +1,93 @@
+package energy.trolie.client.spp;
+
+import energy.trolie.client.RequestHeaderProvider;
+import energy.trolie.client.TrolieRequestContext;
+import lombok.extern.slf4j.Slf4j;
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+
+import java.nio.charset.StandardCharsets;
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+import java.time.Clock;
+import java.time.format.DateTimeFormatter;
+import java.time.temporal.ChronoUnit;
+import java.util.Base64;
+import java.util.Locale;
+import java.util.Map;
+import java.util.UUID;
+
+/**
+ * A {@link RequestHeaderProvider} that implements the SPP Two-Factor Authentication
+ * (TFA) token generation as specified in the SPP TFA Technical Specifications v1.4.
+ *
+ * For every outgoing request, this provider generates a fresh {@code X-SPP-API-Token}
+ * header containing:
+ *
+ * - A UTC timestamp
+ * - A unique nonce (UUID, max 128 characters)
+ * - An HMAC-SHA512 signature over the request metadata
+ *
+ */
+
+@Slf4j
+public final class SppApiTokenHeaderProvider implements RequestHeaderProvider {
+
+ private static final String HEADER_NAME = "X-SPP-API-Token";
+
+
+ private final String screenName;
+ private final byte[] decodedApiKey;
+ private final Clock clock;
+
+ /**
+ * Creates an {@link SppApiTokenHeaderProvider} with the given credentials.
+ *
+ * @param screenName the SPP-assigned screen name for this client
+ * @param apiKey the Base64-encoded API key assigned by SPP
+ * @param clock the clock to use for timestamp generation (use {@link Clock#systemUTC()} in production)
+ */
+ public SppApiTokenHeaderProvider(String screenName, String apiKey, Clock clock) {
+ this.screenName = screenName;
+ this.decodedApiKey = Base64.getDecoder().decode(apiKey);
+ this.clock = clock;
+ }
+
+ @Override
+ public Map headersFor(TrolieRequestContext context) {
+ // 1. Generate timestamp (truncated to seconds, ISO-8601)
+ String timestamp = DateTimeFormatter.ISO_INSTANT
+ .format(clock.instant().truncatedTo(ChronoUnit.SECONDS));
+
+ // 2. Generate unique nonce (UUID satisfies the 128-char max requirement)
+ String nonce = UUID.randomUUID().toString();
+
+ // 3. Lowercase the screen name and request path (as per TFA spec)
+ String lowerScreenName = screenName.toLowerCase(Locale.ROOT);
+ String lowerPath = context.uri().getPath().toLowerCase(Locale.ROOT);
+
+ // 4. Build the string to sign
+ String stringToSign = nonce + timestamp + lowerScreenName + lowerPath;
+ log.debug("Creating SPP API token with string to sign: {}", stringToSign);
+
+ // 5. Compute HMAC-SHA512 and Base64-encode
+ byte[] hmacBytes;
+ try{
+ Mac mac = Mac.getInstance("HmacSHA512");
+ SecretKeySpec secretKeySpec = new SecretKeySpec(decodedApiKey, "HmacSHA512");
+ mac.init(secretKeySpec);
+ hmacBytes = mac.doFinal(stringToSign.getBytes(StandardCharsets.UTF_8));
+ } catch (NoSuchAlgorithmException | InvalidKeyException e) {
+ throw new RuntimeException(e);
+ }
+ var hmacHash = Base64.getEncoder().encodeToString(hmacBytes);
+
+ log.debug("Generated HMAC hash: {}", hmacHash);
+
+ // 6. Assemble the final token
+ String token = timestamp + "-" + nonce + "-" + hmacHash;
+
+ return Map.of(HEADER_NAME, token);
+ }
+
+}
\ No newline at end of file
diff --git a/java-client/src/test/java/energy/trolie/client/TrolieClientIT.java b/java-client/src/test/java/energy/trolie/client/TrolieClientIT.java
index 688252b..8dbefa2 100644
--- a/java-client/src/test/java/energy/trolie/client/TrolieClientIT.java
+++ b/java-client/src/test/java/energy/trolie/client/TrolieClientIT.java
@@ -33,6 +33,7 @@
import energy.trolie.client.request.operatingsnapshots.SeasonalSnapshotSubscribedReceiver;
import energy.trolie.client.request.ratingproposals.ForecastRatingProposalUpdate;
import energy.trolie.client.request.ratingproposals.RealTimeRatingProposalUpdate;
+import energy.trolie.client.spp.SppApiTokenHeaderProvider;
import lombok.extern.slf4j.Slf4j;
import org.apache.hc.client5.http.HttpHostConnectException;
import org.apache.hc.client5.http.classic.HttpClient;
@@ -49,6 +50,7 @@
import org.apache.hc.core5.http.HttpRequestInterceptor;
import org.apache.hc.core5.http.HttpResponseInterceptor;
import org.apache.hc.core5.http.HttpStatus;
+import org.apache.hc.core5.http.ProtocolException;
import org.apache.hc.core5.http.impl.bootstrap.HttpServer;
import org.apache.hc.core5.http.impl.io.DefaultBHttpServerConnectionFactory;
import org.apache.hc.core5.http.impl.io.HttpService;
@@ -62,13 +64,22 @@
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
import javax.net.ServerSocketFactory;
import java.io.IOException;
import java.io.PipedInputStream;
import java.io.PipedOutputStream;
import java.net.InetAddress;
import java.net.ServerSocket;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+import java.time.Clock;
import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.Base64;
import java.util.Collections;
import java.util.List;
import java.util.Map;
@@ -80,6 +91,7 @@
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Function;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -1781,6 +1793,112 @@ public void setSubscription(RequestSubscription subscription) {
}
}
+ @Test
+ void testDynamicRequestHeaderProvider() throws IOException {
+ String expectedHeaderName = "X-TROLIE-Auth";
+ String expectedHeaderValue = "dynamic-token-123";
+
+ // Setup the request handler to verify the header exists
+ requestHandler = request -> {
+ try {
+ Assertions.assertNotNull(request.getHeader(expectedHeaderName), "Header should be present");
+ } catch (ProtocolException e) {
+ throw new RuntimeException(e);
+ }
+ try {
+ Assertions.assertEquals(expectedHeaderValue, request.getHeader(expectedHeaderName).getValue());
+ } catch (ProtocolException e) {
+ throw new RuntimeException(e);
+ }
+
+ BasicClassicHttpResponse response = new BasicClassicHttpResponse(200);
+
+ ForecastRatingProposalStatus status = ForecastRatingProposalStatus.builder().build();
+ try {
+ String jsonResponse = objectMapper.writeValueAsString(status);
+ response.setEntity(new StringEntity(jsonResponse, ContentType.APPLICATION_JSON));
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+
+ return response;
+ };
+
+ // Configure the client to use the new provider
+ HttpClientBuilder builder = HttpClientBuilder.create();
+ RequestHeaderProvider headerProvider = (context) -> Map.of(expectedHeaderName, expectedHeaderValue);
+
+ try (TrolieClient trolieClient = new TrolieClientBuilder(baseUri + "/test-path", builder.build())
+ .addRequestHeaderProvider( headerProvider)
+ .build()) {
+
+ //Perform a request that triggers the header provider
+ try (ForecastRatingProposalUpdate update = trolieClient.createForecastRatingProposalStreamingUpdate()) {
+ update.begin(ForecastProposalHeader.builder().begins(Instant.now()).build());
+ update.complete();
+ }
+ }
+ }
+
+ @Test
+ void testSppApiTokenHeaderProvider_generatesCorrectHeader() throws NoSuchAlgorithmException, InvalidKeyException {
+ // Arrange
+ String screenName = "TestScreenName";
+ String rawApiKey = "my-secret-api-key-987654321";
+ String base64ApiKey = Base64.getEncoder().encodeToString(rawApiKey.getBytes(StandardCharsets.UTF_8));
+ Instant fixedInstant = Instant.parse("2026-06-18T12:34:56Z");
+ Clock clock = Clock.fixed(fixedInstant, ZoneOffset.UTC);
+
+ SppApiTokenHeaderProvider provider = new SppApiTokenHeaderProvider(screenName, base64ApiKey, clock);
+
+ TrolieRequestContext context = new TrolieRequestContext(
+ "GET",
+ URI.create("https://trolie.example.com/api/v1/ratings?query=abc"),
+ "application/json"
+ );
+
+ // Act
+ Map headers = provider.headersFor(context);
+
+ // Assert
+ assertNotNull(headers);
+ assertTrue(headers.containsKey("X-SPP-API-Token"));
+
+ String token = headers.get("X-SPP-API-Token");
+ assertNotNull(token);
+
+ // Token format should be: timestamp-nonce-hmacHash
+ // Timestamp is "2026-06-18T12:34:56Z" (length 20)
+ String expectedTimestamp = "2026-06-18T12:34:56Z";
+ assertTrue(token.startsWith(expectedTimestamp + "-"), "Token should start with the expected timestamp followed by a hyphen");
+
+ // Extract materials following the prefix
+ String remainder = token.substring(expectedTimestamp.length() + 1);
+
+ // A UUID has 36 characters (e.g., 8-4-4-4-12)
+ assertTrue(remainder.length() > 36, "Token remainder must be longer than UUID length");
+ String nonceStr = remainder.substring(0, 36);
+ // Verify it is a valid UUID
+ assertDoesNotThrow(() -> UUID.fromString(nonceStr), "Nonce should be a valid UUID");
+
+ assertEquals('-', remainder.charAt(36), "Character after UUID should be a hyphen");
+ String hmacHash = remainder.substring(37);
+
+ // Verify HMAC authenticity
+ String lowerScreenName = "testscreenname";
+ String lowerPath = "/api/v1/ratings";
+ String expectedStringToSign = nonceStr + expectedTimestamp + lowerScreenName + lowerPath;
+
+ byte[] expectedHmacBytes;
+ Mac mac = Mac.getInstance("HmacSHA512");
+ SecretKeySpec secretKeySpec = new SecretKeySpec(rawApiKey.getBytes(StandardCharsets.UTF_8), "HmacSHA512");
+ mac.init(secretKeySpec);
+ expectedHmacBytes = mac.doFinal(expectedStringToSign.getBytes(StandardCharsets.UTF_8));
+ String expectedHmacHash = Base64.getEncoder().encodeToString(expectedHmacBytes);
+
+ assertEquals(expectedHmacHash, hmacHash, "HMAC signature should match the expected signature over metadata");
+ }
+
private void writeMonitoringSet(JsonGenerator json, String id) throws IOException {
var source = DataProvenance.builder().provider(id).lastUpdated(
Instant.now()).originId(id).build();
@@ -1895,4 +2013,292 @@ private void writeSeasonalSnapshot(JsonGenerator json, Instant startTime, String
}
+ @Test
+ void testRequestHeaderProviderOnParameterizedGet() throws Exception {
+ // Verify that the RequestHeaderProvider is invoked on the GET path,
+ // that context.uri().getQuery() is non-null and contains the expected
+ // query parameters, and that context.contentType() is null for GETs.
+
+ Instant startTime = Instant.now();
+ String startTimeString = startTime.toString();
+
+ requestHandler = request -> {
+ BasicClassicHttpResponse response = new BasicClassicHttpResponse(200);
+ try {
+ PipedOutputStream out = new PipedOutputStream();
+ PipedInputStream in = new PipedInputStream(out);
+ response.setEntity(new GzipCompressingEntity(new InputStreamEntity(in,
+ ContentType.create(TrolieApiConstants.CONTENT_TYPE_FORECAST_SNAPSHOT))));
+ response.addHeader(HttpHeaders.CONTENT_ENCODING, "gzip");
+ threadPoolExecutor.submit((Callable) () -> {
+ try (JsonGenerator json = new JsonFactory(objectMapper).createGenerator(out)) {
+ writeForecastSnapshot(json, startTime);
+ return null;
+ }
+ });
+ } catch (Exception e) {
+ e.printStackTrace();
+ response.setCode(HttpStatus.SC_INTERNAL_SERVER_ERROR);
+ }
+ return response;
+ };
+
+ AtomicInteger providerInvocationCount = new AtomicInteger(0);
+ // Captured context fields
+ String[] capturedQuery = {null};
+ String[] capturedMethod = {null};
+ String[] capturedContentType = {"NOT_SET"};
+
+ RequestHeaderProvider provider = context -> {
+ providerInvocationCount.incrementAndGet();
+ capturedMethod[0] = context.method();
+ capturedQuery[0] = context.uri().getQuery();
+ capturedContentType[0] = context.contentType();
+ return Map.of();
+ };
+
+ HttpClientBuilder builder = HttpClientBuilder.create();
+ try (TrolieClient trolieClient = new TrolieClientBuilder(baseUri, builder.build())
+ .addRequestHeaderProvider(provider)
+ .build()) {
+
+ trolieClient.getInUseLimitForecasts(new ForecastSnapshotReceiver() {
+ @Override public void header(ForecastSnapshotHeader header) {}
+ @Override public void beginSnapshot() {}
+ @Override public void endSnapshot() {}
+ @Override public void beginResource(String resourceId) {}
+ @Override public void period(ForecastPeriodSnapshot period) {}
+ @Override public void endResource() {}
+ }, "abc", null, startTime, startTime);
+ }
+
+ // Provider must have been called exactly once for the single GET
+ Assertions.assertEquals(1, providerInvocationCount.get(),
+ "Provider should be invoked once for the GET request");
+
+ // Method must be GET
+ Assertions.assertEquals("GET", capturedMethod[0]);
+
+ // Query must be non-null and contain the expected parameters
+ Assertions.assertNotNull(capturedQuery[0],
+ "context.uri().getQuery() must not be null for a parameterized GET");
+ Assertions.assertTrue(capturedQuery[0].contains(TrolieApiConstants.PARAM_MONITORING_SET + "=abc"),
+ "Query should contain monitoring-set param");
+ Assertions.assertTrue(capturedQuery[0].contains(TrolieApiConstants.PARAM_OFFSET_PERIOD_START + "=" + startTimeString),
+ "Query should contain offset-period-start param");
+ Assertions.assertTrue(capturedQuery[0].contains(TrolieApiConstants.PARAM_PERIOD_END + "=" + startTimeString),
+ "Query should contain period-end param");
+
+ // contentType must be null for a GET (no request body)
+ Assertions.assertNull(capturedContentType[0],
+ "context.contentType() must be null for GET requests");
+ }
+
+ @Test
+ void testMultipleProvidersAndStaticHeaderPrecedence() throws IOException {
+ // Validates the documented merge order:
+ // static httpHeaders → provider1 → provider2 (later values overwrite earlier ones)
+ //
+ // Setup:
+ // X-Static set by static headers ("static-value")
+ // then overwritten by provider1 ("provider1-value")
+ // X-P1-Only set only by provider1 ("p1-only")
+ // then overwritten by provider2 ("provider2-wins")
+ // X-P2-Only set only by provider2 ("p2-only")
+ //
+ // Expected on the server:
+ // X-Static = "provider1-value" (provider1 overwrites static)
+ // X-P1-Only = "provider2-wins" (provider2 overwrites provider1)
+ // X-P2-Only = "p2-only" (only provider2 touches this key)
+
+ requestHandler = request -> {
+ try {
+ Assertions.assertEquals("provider1-value",
+ request.getHeader("X-Static").getValue(),
+ "provider1 must overwrite the static header value");
+ Assertions.assertEquals("provider2-wins",
+ request.getHeader("X-P1-Only").getValue(),
+ "provider2 must overwrite provider1 for the same key");
+ Assertions.assertEquals("p2-only",
+ request.getHeader("X-P2-Only").getValue(),
+ "provider2-only header must be present");
+ } catch (ProtocolException e) {
+ throw new RuntimeException(e);
+ }
+
+ BasicClassicHttpResponse response = new BasicClassicHttpResponse(200);
+ try {
+ response.setEntity(new StringEntity(
+ objectMapper.writeValueAsString(ForecastRatingProposalStatus.builder().build()),
+ ContentType.APPLICATION_JSON));
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ return response;
+ };
+
+ HttpClientBuilder builder = HttpClientBuilder.create();
+ try (TrolieClient trolieClient = new TrolieClientBuilder(baseUri + "/test-path", builder.build())
+ .httpHeaders(Map.of("X-Static", "static-value"))
+ .addRequestHeaderProvider(ctx -> Map.of(
+ "X-Static", "provider1-value", // overwrites static
+ "X-P1-Only", "p1-only"))
+ .addRequestHeaderProvider(ctx -> Map.of(
+ "X-P1-Only", "provider2-wins", // overwrites provider1
+ "X-P2-Only", "p2-only"))
+ .build()) {
+
+ try (ForecastRatingProposalUpdate update = trolieClient.createForecastRatingProposalStreamingUpdate()) {
+ update.begin(ForecastProposalHeader.builder().begins(Instant.now()).build());
+ update.complete();
+ }
+ }
+ }
+
+ @Test
+ void testProviderReInvocationOnSubscribedGetPolls() throws Exception {
+ // Test that verifies providers are re-called on repeated poll cycles.
+ // This validates that for subscribed-GET requests, the RequestHeaderProvider
+ // is invoked fresh on each poll cycle, ensuring dynamic content (e.g., auth tokens, nonces)
+ // is regenerated for every request.
+
+ AtomicInteger requestCounter = new AtomicInteger(0);
+ AtomicInteger providerInvocationCount = new AtomicInteger(0);
+ String etag = UUID.randomUUID().toString();
+ Instant startTime = Instant.now();
+
+ requestHandler = request -> {
+ BasicClassicHttpResponse response = new BasicClassicHttpResponse(200);
+
+ try {
+ // Verify that the request has the expected header from the provider
+ Header dynamicHeader = request.getHeader("X-Provider-Test");
+ Assertions.assertNotNull(dynamicHeader, "Provider header should be present on every request");
+ Assertions.assertEquals("provider-invocation-" + requestCounter.get(), dynamicHeader.getValue());
+
+ if (requestCounter.get() == 0) {
+ // First poll: return forecast snapshot with etag
+ PipedOutputStream out = new PipedOutputStream();
+ PipedInputStream in = new PipedInputStream(out);
+
+ response.setHeader(HttpHeaders.ETAG, etag);
+ response.setEntity(
+ new GzipCompressingEntity(new InputStreamEntity(in, ContentType.create(TrolieApiConstants.CONTENT_TYPE_FORECAST_SNAPSHOT))));
+ response.addHeader(HttpHeaders.CONTENT_ENCODING, "gzip");
+ threadPoolExecutor.submit(new Callable() {
+ @Override
+ public Void call() throws Exception {
+ try (JsonGenerator json = new JsonFactory(objectMapper).createGenerator(out)) {
+ writeForecastSnapshot(json, startTime);
+ return null;
+ } catch (Exception e) {
+ e.printStackTrace();
+ throw new RuntimeException(e);
+ }
+ }
+ });
+ } else if (requestCounter.get() == 1) {
+ // Second poll: return 304 Not Modified (provider should still be called)
+ response.setCode(HttpStatus.SC_NOT_MODIFIED);
+ }
+
+ } catch (Exception e) {
+ e.printStackTrace();
+ response.setCode(HttpStatus.SC_INTERNAL_SERVER_ERROR);
+ } finally {
+ requestCounter.incrementAndGet();
+ }
+
+ return response;
+ };
+
+ HttpClientBuilder builder = HttpClientBuilder.create();
+ try (TrolieClient trolieClient = new TrolieClientBuilder(baseUri, builder.build())
+ .forecastRatingsPollMs(200)
+ .addRequestHeaderProvider(context -> {
+ // Increment provider invocation counter
+ int invocationNumber = providerInvocationCount.getAndIncrement();
+ log.info("Provider invoked for poll cycle {}", invocationNumber);
+ // Return headers that include the invocation number for verification
+ return Map.of("X-Provider-Test", "provider-invocation-" + requestCounter.get());
+ })
+ .build()) {
+
+ AtomicInteger snapshotsReceived = new AtomicInteger(0);
+ AtomicInteger errorCount = new AtomicInteger(0);
+
+ // Subscribe and run for two poll cycles
+ var subscription = trolieClient.subscribeToInUseLimitForecastUpdates(new ForecastSnapshotSubscribedReceiver() {
+
+ RequestSubscription subscription;
+ int numResources;
+ int numPeriods;
+
+ @Override
+ public void period(ForecastPeriodSnapshot period) {
+ numPeriods++;
+ }
+
+ @Override
+ public void header(ForecastSnapshotHeader header) {
+ Assertions.assertEquals(startTime, header.getBegins());
+ }
+
+ @Override
+ public void endSnapshot() {
+ Assertions.assertEquals(100, numResources);
+ numResources = 0;
+ }
+
+ @Override
+ public void endResource() {
+ Assertions.assertEquals(24, numPeriods);
+ numPeriods = 0;
+ }
+
+ @Override
+ public void beginSnapshot() {
+ snapshotsReceived.incrementAndGet();
+ }
+
+ @Override
+ public void beginResource(String resourceId) {
+ numResources++;
+ }
+
+ @Override
+ public void error(StreamingGetException t) {
+ errorCount.incrementAndGet();
+ ((RequestSubscriptionInternal)subscription).stop();
+ }
+
+ @Override
+ public void setSubscription(RequestSubscription subscription) {
+ this.subscription = subscription;
+ }
+
+ }, "abc");
+
+ // Wait for two poll cycles to complete
+ // First request sends data, second request gets 304 Not Modified
+ int waitCycles = 0;
+ while (subscription.isSubscribed() && waitCycles < 20) {
+ Thread.sleep(100);
+ waitCycles++;
+ if (requestCounter.get() >= 2) {
+ ((RequestSubscriptionInternal) subscription).stop();
+ break;
+ }
+ }
+
+ // Verify the provider was invoked on each poll cycle
+ Assertions.assertEquals(2, providerInvocationCount.get(),
+ "Provider should be invoked exactly twice (once per poll cycle)");
+ Assertions.assertEquals(1, snapshotsReceived.get(),
+ "Should have received one snapshot on first poll");
+ Assertions.assertEquals(0, errorCount.get(),
+ "Should have no errors during subscription");
+ }
+ }
+
}