From 1ac39a1e7507523de13f79ac74ac227eef98570a Mon Sep 17 00:00:00 2001 From: Dumitru Samuel Irinel Date: Fri, 5 Jun 2026 12:32:33 +0300 Subject: [PATCH 01/12] add dynamic request header provider support and unit tests --- .../trolie/client/RequestHeaderProvider.java | 8 +++ .../trolie/client/TrolieClientBuilder.java | 32 +++++++++++- .../trolie/client/TrolieRequestContext.java | 8 +++ .../trolie/client/impl/TrolieClientImpl.java | 28 ++++++++--- .../impl/request/AbstractStreamingGet.java | 27 ++++++++++ .../AbstractStreamingSubscribedGet.java | 5 +- .../impl/request/AbstractStreamingUpdate.java | 38 ++++++++++++-- .../DefaultMonitoringSetRequest.java | 5 +- ...DefaultMonitoringSetSubscribedRequest.java | 5 +- .../monitoringsets/MonitoringSetsRequest.java | 5 +- .../MonitoringSetsSubscribedRequest.java | 5 +- .../ForecastSnapshotRequest.java | 5 +- .../ForecastSnapshotSubscribedRequest.java | 5 +- .../RealTimeSnapshotRequest.java | 5 +- .../RealTimeSnapshotSubscribedRequest.java | 5 +- .../RegionalForecastSnapshotRequest.java | 5 +- ...onalForecastSubscribedSnapshotRequest.java | 5 +- .../RegionalRealTimeSnapshotRequest.java | 5 +- ...onalRealTimeSnapshotSubscribedRequest.java | 5 +- .../SeasonalSnapshotRequest.java | 5 +- .../SeasonalSnapshotSubscribedRequest.java | 5 +- .../ForecastRatingProposalUpdate.java | 7 ++- .../RealTimeRatingProposalUpdate.java | 7 ++- .../energy/trolie/client/TrolieClientIT.java | 49 +++++++++++++++++++ 24 files changed, 249 insertions(+), 30 deletions(-) create mode 100644 java-client/src/main/java/energy/trolie/client/RequestHeaderProvider.java create mode 100644 java-client/src/main/java/energy/trolie/client/TrolieRequestContext.java diff --git a/java-client/src/main/java/energy/trolie/client/RequestHeaderProvider.java b/java-client/src/main/java/energy/trolie/client/RequestHeaderProvider.java new file mode 100644 index 0000000..86c9750 --- /dev/null +++ b/java-client/src/main/java/energy/trolie/client/RequestHeaderProvider.java @@ -0,0 +1,8 @@ +package energy.trolie.client; + +import java.util.Map; + +public interface RequestHeaderProvider { + + Map headersFor(TrolieRequestContext requestContext); +} diff --git a/java-client/src/main/java/energy/trolie/client/TrolieClientBuilder.java b/java-client/src/main/java/energy/trolie/client/TrolieClientBuilder.java index 0700e25..561ba91 100644 --- a/java-client/src/main/java/energy/trolie/client/TrolieClientBuilder.java +++ b/java-client/src/main/java/energy/trolie/client/TrolieClientBuilder.java @@ -13,7 +13,9 @@ import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; import org.apache.hc.client5.http.impl.classic.HttpClients; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; /** @@ -50,6 +52,7 @@ public class TrolieClientBuilder { private ObjectMapper objectMapper; private ETagStore eTagStore; private Map httpHeaders = new HashMap<>(); + private final List providers = new ArrayList<>(); private int periodLengthMinutes = 60; private int realTimeRatingsPollMs = 10000; @@ -134,6 +137,33 @@ public TrolieClientBuilder httpHeaders(Map httpHeaders) { return this; } + /** + * Adds a request header provider that can generate additional headers for each request execution. + * Providers are invoked when a request is built, and their output is not cached across retries + * or repeated executions. + * + * @param provider provider used to generate request-specific headers + * @return fluent builder + */ + public TrolieClientBuilder addRequestHeaderProvider(RequestHeaderProvider provider) { + this.providers.add(provider); + return this; + } + + /** + * Replaces the current set of request header providers with the given list. + * Providers are invoked when a request is built, and their output is not cached across retries + * or repeated executions. + * + * @param providers list of providers used to generate request-specific headers + * @return fluent builder + */ + public TrolieClientBuilder requestHeaderProviders(List providers) { + this.providers.clear(); + this.providers.addAll(providers); + return this; + } + /** * Sets the period length assumed for forecast ratings. Defaults to 60 minutes. * @param periodLengthMinutes new assumed period length. @@ -216,7 +246,7 @@ public TrolieClient build() { } return new TrolieClientImpl(httpClient, host, requestConfig, bufferSize, - objectMapper, eTagStore, httpHeaders, periodLengthMinutes, + objectMapper, eTagStore, httpHeaders, providers, periodLengthMinutes, realTimeRatingsPollMs, forecastRatingsPollMs, monitoringSetPollMs, seasonalRatingsPollMs); } diff --git a/java-client/src/main/java/energy/trolie/client/TrolieRequestContext.java b/java-client/src/main/java/energy/trolie/client/TrolieRequestContext.java new file mode 100644 index 0000000..ebebbc3 --- /dev/null +++ b/java-client/src/main/java/energy/trolie/client/TrolieRequestContext.java @@ -0,0 +1,8 @@ +package energy.trolie.client; + +import java.net.URI; + +public record TrolieRequestContext(String method, URI uri, String contentType) { +} + + diff --git a/java-client/src/main/java/energy/trolie/client/impl/TrolieClientImpl.java b/java-client/src/main/java/energy/trolie/client/impl/TrolieClientImpl.java index 807c92e..a3d5c37 100644 --- a/java-client/src/main/java/energy/trolie/client/impl/TrolieClientImpl.java +++ b/java-client/src/main/java/energy/trolie/client/impl/TrolieClientImpl.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.RequestSubscription; import energy.trolie.client.TrolieClient; import energy.trolie.client.TrolieHost; @@ -38,6 +39,7 @@ import java.io.IOException; import java.time.Instant; import java.util.HashSet; +import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.ExecutionException; @@ -54,6 +56,7 @@ public class TrolieClientImpl implements TrolieClient { ObjectMapper objectMapper; ETagStore eTagStore; Map httpHeaders; + List providers; private final int defaultIntervalMinutes; private final int realTimeRatingsPollMs; private final int forecastRatingsPollMs; @@ -61,7 +64,7 @@ public class TrolieClientImpl implements TrolieClient { private final int seasonalRatingsPollMs; public TrolieClientImpl(CloseableHttpClient httpClient, TrolieHost host, RequestConfig requestConfig, int bufferSize, - ObjectMapper objectMapper, ETagStore eTagStore, Map httpHeaders, + ObjectMapper objectMapper, ETagStore eTagStore, Map httpHeaders, List providers, int defaultIntervalMinutes, int realTimeRatingsPollMs, int forecastRatingsPollMs, @@ -75,6 +78,7 @@ public TrolieClientImpl(CloseableHttpClient httpClient, TrolieHost host, Request this.objectMapper = objectMapper; this.eTagStore = eTagStore; this.httpHeaders = httpHeaders; + this.providers = providers; this.defaultIntervalMinutes = defaultIntervalMinutes; this.realTimeRatingsPollMs = realTimeRatingsPollMs; this.forecastRatingsPollMs = forecastRatingsPollMs; @@ -121,6 +125,7 @@ public void getInUseLimitForecasts( bufferSize, objectMapper, httpHeaders, + providers, receiver, monitoringSet, resourceId, @@ -147,6 +152,7 @@ public ForecastSnapshotSubscribedRequest subscribeToInUseLimitForecastUpdates( bufferSize, objectMapper, httpHeaders, + providers, forecastRatingsPollMs, receiver, eTagStore, @@ -187,6 +193,7 @@ public void getRegionalLimitsForecast( bufferSize, objectMapper, httpHeaders, + providers, receiver, monitoringSet, resourceId, @@ -214,6 +221,7 @@ public RegionalForecastSubscribedSnapshotRequest subscribeToRegionalLimitsForeca bufferSize, objectMapper, httpHeaders, + providers, forecastRatingsPollMs, receiver, eTagStore, @@ -226,7 +234,7 @@ public RegionalForecastSubscribedSnapshotRequest subscribeToRegionalLimitsForeca @Override public ForecastRatingProposalUpdate createForecastRatingProposalStreamingUpdate() { return new ForecastRatingProposalUpdate(httpClient, host, requestConfig, bufferSize, - objectMapper, httpHeaders, defaultIntervalMinutes); + objectMapper, httpHeaders, providers, defaultIntervalMinutes); } @Override @@ -251,6 +259,7 @@ public RealTimeSnapshotSubscribedRequest subscribeToInUseLimits(RealTimeSnapshot bufferSize, objectMapper, httpHeaders, + providers, realTimeRatingsPollMs, receiver, eTagStore, @@ -264,7 +273,7 @@ public RealTimeSnapshotSubscribedRequest subscribeToInUseLimits(RealTimeSnapshot @Override public RealTimeRatingProposalUpdate createRealTimeRatingProposalStreamingUpdate() { return new RealTimeRatingProposalUpdate(httpClient, host, requestConfig, bufferSize, - objectMapper, httpHeaders); + objectMapper, httpHeaders, providers); } @Override @@ -287,6 +296,7 @@ public void getInUseLimits(RealTimeSnapshotReceiver receiver, String monitoringS bufferSize, objectMapper, httpHeaders, + providers, receiver, monitoringSet, resourceId).executeRequest(); @@ -313,6 +323,7 @@ public void getRegionalRealTimeLimits(RealTimeSnapshotReceiver receiver, String bufferSize, objectMapper, httpHeaders, + providers, receiver, monitoringSet, resourceId).executeRequest(); @@ -336,6 +347,7 @@ public RegionalRealTimeSnapshotSubscribedRequest subscribeToRegionalRealTimeLimi bufferSize, objectMapper, httpHeaders, + providers, realTimeRatingsPollMs, receiver, eTagStore, @@ -348,7 +360,7 @@ public RegionalRealTimeSnapshotSubscribedRequest subscribeToRegionalRealTimeLimi @Override public void getMonitoringSet(MonitoringSetsReceiver receiver, String monitoringSet) { new MonitoringSetsRequest( - httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, + httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, providers, receiver, monitoringSet).executeRequest(); } @@ -357,7 +369,7 @@ public void getMonitoringSet(MonitoringSetsReceiver receiver, String monitoringS public MonitoringSetsSubscribedRequest subscribeToMonitoringSetUpdates(MonitoringSetsSubscribedReceiver receiver, String monitoringSet) { MonitoringSetsSubscribedRequest subscription = new MonitoringSetsSubscribedRequest( - httpClient, host, requestConfig, monitoringSetPollMs, objectMapper, httpHeaders, + httpClient, host, requestConfig, monitoringSetPollMs, objectMapper, httpHeaders, providers, monitoringSetPollMs, receiver, eTagStore, monitoringSet); addSubscription(subscription); return subscription; @@ -366,7 +378,7 @@ public MonitoringSetsSubscribedRequest subscribeToMonitoringSetUpdates(Monitorin @Override public void getDefaultMonitoringSet(MonitoringSetsReceiver receiver) { new DefaultMonitoringSetRequest( - httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, receiver) + httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, providers, receiver) .executeRequest(); } @@ -374,7 +386,7 @@ public void getDefaultMonitoringSet(MonitoringSetsReceiver receiver) { public DefaultMonitoringSetSubscribedRequest subscribeToDefaultMonitoringSetUpdates( MonitoringSetsSubscribedReceiver receiver) { var subscription = new DefaultMonitoringSetSubscribedRequest( - httpClient, host, requestConfig, monitoringSetPollMs, objectMapper, httpHeaders, + httpClient, host, requestConfig, monitoringSetPollMs, objectMapper, httpHeaders, providers, monitoringSetPollMs, receiver, eTagStore); addSubscription(subscription); return subscription; @@ -403,6 +415,7 @@ public void getInUseSeasonalSnapshots( bufferSize, objectMapper, httpHeaders, + providers, receiver, monitoringSet, resourceId).executeRequest(); @@ -426,6 +439,7 @@ public SeasonalSnapshotSubscribedRequest subscribeToInUseSeasonalSnapshotUpdates bufferSize, objectMapper, httpHeaders, + providers, seasonalRatingsPollMs, receiver, eTagStore, diff --git a/java-client/src/main/java/energy/trolie/client/impl/request/AbstractStreamingGet.java b/java-client/src/main/java/energy/trolie/client/impl/request/AbstractStreamingGet.java index e862920..5878850 100644 --- a/java-client/src/main/java/energy/trolie/client/impl/request/AbstractStreamingGet.java +++ b/java-client/src/main/java/energy/trolie/client/impl/request/AbstractStreamingGet.java @@ -2,9 +2,11 @@ import com.fasterxml.jackson.core.JsonFactory; import com.fasterxml.jackson.databind.ObjectMapper; +import energy.trolie.client.RequestHeaderProvider; import energy.trolie.client.StreamingResponseReceiver; import energy.trolie.client.StreamingSubscribedResponseReceiver; import energy.trolie.client.TrolieHost; +import energy.trolie.client.TrolieRequestContext; import energy.trolie.client.exception.StreamingGetConnectionException; import energy.trolie.client.exception.StreamingGetException; import energy.trolie.client.exception.StreamingGetResponseException; @@ -23,6 +25,8 @@ import java.io.IOException; import java.io.InputStream; 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.LinkedBlockingDeque; @@ -45,6 +49,7 @@ public abstract class AbstractStreamingGet int bufferSize; ThreadPoolExecutor threadPoolExecutor; Map httpHeaders; + List providers; protected boolean lastRequestFailed = false; protected JsonFactory jsonFactory; @@ -73,6 +78,7 @@ protected AbstractStreamingGet( int bufferSize, ObjectMapper objectMapper, Map httpHeaders, + List providers, T receiver) { super(); this.httpClient = httpClient; @@ -84,6 +90,7 @@ protected AbstractStreamingGet( this.threadPoolExecutor = new ThreadPoolExecutor(2,2,10, TimeUnit.SECONDS, new LinkedBlockingDeque<>()); this.httpHeaders = httpHeaders; + this.providers = providers; } protected HttpClientResponseHandler createResponseHandler() { @@ -125,6 +132,22 @@ protected boolean handleResponse(ClassicHttpResponse response) { return false; } + + protected void applyRequestHeaderProviders(HttpGet request) throws URISyntaxException { + TrolieRequestContext context = new TrolieRequestContext(request.getMethod(), + request.getUri(), + getContentType()); + var mergedHeaders = new LinkedHashMap(); + if (httpHeaders != null) { + mergedHeaders.putAll(httpHeaders); + } + + for (var provider : providers) { + mergedHeaders.putAll(provider.headersFor(context)); + } + + mergedHeaders.forEach(request::setHeader); + } protected HttpGet createRequest() throws URISyntaxException { HttpGet get = new HttpGet(getFullPath()); @@ -132,6 +155,10 @@ protected HttpGet createRequest() throws URISyntaxException { if (httpHeaders != null && !httpHeaders.isEmpty()) { httpHeaders.forEach(get::addHeader); } + + if (providers != null && !providers.isEmpty()) { + applyRequestHeaderProviders(get); + } get.setConfig(requestConfig); return get; 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..f138766 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; @@ -23,6 +25,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 +55,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 +68,7 @@ protected AbstractStreamingUpdate(HttpClient httpClient, TrolieHost host, Reques this.bufferSize = bufferSize; this.objectMapper = objectMapper; this.httpHeaders = httpHeaders; + this.providers = providers; } /** @@ -122,9 +129,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 +143,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 +163,27 @@ protected OutputStream createRequestOutputStream() throws IOException { return outputStream; } + protected void applyRequestHeaderProviders(HttpUriRequestBase request) throws URISyntaxException { + var context = new TrolieRequestContext( + request.getMethod(), + request.getUri(), + request.getFirstHeader(HttpHeaders.CONTENT_TYPE) != null + ? request.getFirstHeader(HttpHeaders.CONTENT_TYPE).getValue() + : null + ); + + var mergedHeaders = new LinkedHashMap(); + if (httpHeaders != null) { + mergedHeaders.putAll(httpHeaders); + } + + 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/test/java/energy/trolie/client/TrolieClientIT.java b/java-client/src/test/java/energy/trolie/client/TrolieClientIT.java index 688252b..56c4fdc 100644 --- a/java-client/src/test/java/energy/trolie/client/TrolieClientIT.java +++ b/java-client/src/test/java/energy/trolie/client/TrolieClientIT.java @@ -49,6 +49,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; @@ -56,6 +57,7 @@ import org.apache.hc.core5.http.io.entity.InputStreamEntity; import org.apache.hc.core5.http.io.entity.StringEntity; import org.apache.hc.core5.http.message.BasicClassicHttpResponse; +import org.apache.hc.core5.http.message.BasicHeader; import org.apache.hc.core5.http.protocol.DefaultHttpProcessor; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.Assertions; @@ -1781,6 +1783,53 @@ 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(); + } + } + } + private void writeMonitoringSet(JsonGenerator json, String id) throws IOException { var source = DataProvenance.builder().provider(id).lastUpdated( Instant.now()).originId(id).build(); From 6498922b8482019b4ccbc9419d3928b8f47416cc Mon Sep 17 00:00:00 2001 From: Dumitru Samuel Irinel Date: Fri, 5 Jun 2026 15:00:59 +0300 Subject: [PATCH 02/12] Delete unused imports --- .../src/test/java/energy/trolie/client/TrolieClientIT.java | 1 - 1 file changed, 1 deletion(-) 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 56c4fdc..ad511e7 100644 --- a/java-client/src/test/java/energy/trolie/client/TrolieClientIT.java +++ b/java-client/src/test/java/energy/trolie/client/TrolieClientIT.java @@ -57,7 +57,6 @@ import org.apache.hc.core5.http.io.entity.InputStreamEntity; import org.apache.hc.core5.http.io.entity.StringEntity; import org.apache.hc.core5.http.message.BasicClassicHttpResponse; -import org.apache.hc.core5.http.message.BasicHeader; import org.apache.hc.core5.http.protocol.DefaultHttpProcessor; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.Assertions; From 6d1d75e285272308da695ce70635fb1bb42cacf4 Mon Sep 17 00:00:00 2001 From: Dumitru Samuel Irinel Date: Tue, 9 Jun 2026 11:37:21 +0300 Subject: [PATCH 03/12] Refactor request header provider lifecycle to ensure final URI and correct Content-Type --- .../client/impl/request/AbstractStreamingGet.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/java-client/src/main/java/energy/trolie/client/impl/request/AbstractStreamingGet.java b/java-client/src/main/java/energy/trolie/client/impl/request/AbstractStreamingGet.java index 5878850..87ec3e5 100644 --- a/java-client/src/main/java/energy/trolie/client/impl/request/AbstractStreamingGet.java +++ b/java-client/src/main/java/energy/trolie/client/impl/request/AbstractStreamingGet.java @@ -134,9 +134,10 @@ protected boolean handleResponse(ClassicHttpResponse response) { } protected void applyRequestHeaderProviders(HttpGet request) throws URISyntaxException { - TrolieRequestContext context = new TrolieRequestContext(request.getMethod(), + + TrolieRequestContext context = new TrolieRequestContext(request.getMethod(), request.getUri(), - getContentType()); + null); var mergedHeaders = new LinkedHashMap(); if (httpHeaders != null) { mergedHeaders.putAll(httpHeaders); @@ -156,10 +157,6 @@ protected HttpGet createRequest() throws URISyntaxException { httpHeaders.forEach(get::addHeader); } - if (providers != null && !providers.isEmpty()) { - applyRequestHeaderProviders(get); - } - get.setConfig(requestConfig); return get; } @@ -168,6 +165,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) { From e35bffb313e4537752de4d4d62889c9534d68b5b Mon Sep 17 00:00:00 2001 From: Dumitru Samuel Irinel Date: Tue, 9 Jun 2026 14:18:09 +0300 Subject: [PATCH 04/12] Updated Javadoc in RequestHeaderProvider and TrolieClientBuilder to accurately reflect that transport-level retries may reuse request headers. Added guidance for implementers to configure retry policies appropriately for security-sensitive headers. --- .../trolie/client/RequestHeaderProvider.java | 10 ++++++++++ .../trolie/client/TrolieClientBuilder.java | 18 ++++++++++++++---- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/java-client/src/main/java/energy/trolie/client/RequestHeaderProvider.java b/java-client/src/main/java/energy/trolie/client/RequestHeaderProvider.java index 86c9750..7fc5b89 100644 --- a/java-client/src/main/java/energy/trolie/client/RequestHeaderProvider.java +++ b/java-client/src/main/java/energy/trolie/client/RequestHeaderProvider.java @@ -2,6 +2,16 @@ import java.util.Map; +/** + * Interface for providing dynamic request headers (e.g., Auth tokens, nonces). + * + *

Implementation Note: Implementations must generate + * fresh values (e.g., new nonces, current timestamps) on every call. The + * underlying {@code HttpClient} may reuse request objects during automatic + * retries. Do not cache values within the provider, as the SDK cannot + * guarantee that this method will be re-invoked for every individual + * transmission attempt.

+ */ public interface RequestHeaderProvider { Map headersFor(TrolieRequestContext requestContext); diff --git a/java-client/src/main/java/energy/trolie/client/TrolieClientBuilder.java b/java-client/src/main/java/energy/trolie/client/TrolieClientBuilder.java index 561ba91..364685f 100644 --- a/java-client/src/main/java/energy/trolie/client/TrolieClientBuilder.java +++ b/java-client/src/main/java/energy/trolie/client/TrolieClientBuilder.java @@ -139,8 +139,13 @@ public TrolieClientBuilder httpHeaders(Map httpHeaders) { /** * Adds a request header provider that can generate additional headers for each request execution. - * Providers are invoked when a request is built, and their output is not cached across retries - * or repeated executions. + * Providers are invoked during the initial request construction. + * + *

Note: If the underlying {@code HttpClient} is configured with + * automatic retries, the same request object (and its headers) may be sent multiple times. + * To ensure security-sensitive headers like nonces are regenerated, users should ensure + * that automatic retries are disabled or that the client is configured to rebuild + * requests on retry.

* * @param provider provider used to generate request-specific headers * @return fluent builder @@ -152,8 +157,13 @@ public TrolieClientBuilder addRequestHeaderProvider(RequestHeaderProvider provid /** * Replaces the current set of request header providers with the given list. - * Providers are invoked when a request is built, and their output is not cached across retries - * or repeated executions. + * Providers are invoked during the initial request construction. + * + *

Note: If the underlying {@code HttpClient} is configured with + * automatic retries, the request (including these headers) may be reused across + * multiple attempts. To guarantee fresh header values (e.g., new nonces) on retry, + * implementers must ensure that automatic transport-level retries are disabled or + * that the client is configured to rebuild requests on retry.

* * @param providers list of providers used to generate request-specific headers * @return fluent builder From 9cfbe85bd8e3c18bc00cfe827dbdf9102350f880 Mon Sep 17 00:00:00 2001 From: Dumitru Samuel Irinel Date: Tue, 9 Jun 2026 15:51:28 +0300 Subject: [PATCH 05/12] add integration tests for RequestHeaderProvider functionality --- .../energy/trolie/client/TrolieClientIT.java | 289 ++++++++++++++++++ 1 file changed, 289 insertions(+) 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 ad511e7..2ea9a00 100644 --- a/java-client/src/test/java/energy/trolie/client/TrolieClientIT.java +++ b/java-client/src/test/java/energy/trolie/client/TrolieClientIT.java @@ -1943,4 +1943,293 @@ 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() {} + @Override public void error(StreamingGetException t) {} + }, "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"); + } + } + } From 218b4e4bb68343e7f026b1be2571df4c98f6de01 Mon Sep 17 00:00:00 2001 From: Dumitru Samuel Irinel Date: Tue, 9 Jun 2026 16:11:31 +0300 Subject: [PATCH 06/12] Eddited unit test --- .../src/test/java/energy/trolie/client/TrolieClientIT.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 2ea9a00..efcf77d 100644 --- a/java-client/src/test/java/energy/trolie/client/TrolieClientIT.java +++ b/java-client/src/test/java/energy/trolie/client/TrolieClientIT.java @@ -1999,8 +1999,7 @@ void testRequestHeaderProviderOnParameterizedGet() throws Exception { @Override public void beginResource(String resourceId) {} @Override public void period(ForecastPeriodSnapshot period) {} @Override public void endResource() {} - @Override public void error(StreamingGetException t) {} - }, "abc", null, startTime, startTime); + }, "abc", null, startTime, startTime); } // Provider must have been called exactly once for the single GET From fe070743b1a22e71d2836f1927fc0381664bc927 Mon Sep 17 00:00:00 2001 From: Dumitru Samuel Irinel Date: Wed, 10 Jun 2026 11:38:36 +0300 Subject: [PATCH 07/12] Removed redundant static header processing from applyRequestHeaderProviders to avoid double-processing. --- .../trolie/client/impl/request/AbstractStreamingGet.java | 3 --- .../trolie/client/impl/request/AbstractStreamingUpdate.java | 3 --- 2 files changed, 6 deletions(-) diff --git a/java-client/src/main/java/energy/trolie/client/impl/request/AbstractStreamingGet.java b/java-client/src/main/java/energy/trolie/client/impl/request/AbstractStreamingGet.java index 87ec3e5..ea5d79e 100644 --- a/java-client/src/main/java/energy/trolie/client/impl/request/AbstractStreamingGet.java +++ b/java-client/src/main/java/energy/trolie/client/impl/request/AbstractStreamingGet.java @@ -139,9 +139,6 @@ protected void applyRequestHeaderProviders(HttpGet request) throws URISyntaxExce request.getUri(), null); var mergedHeaders = new LinkedHashMap(); - if (httpHeaders != null) { - mergedHeaders.putAll(httpHeaders); - } for (var provider : providers) { mergedHeaders.putAll(provider.headersFor(context)); 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 f138766..f670129 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 @@ -173,9 +173,6 @@ protected void applyRequestHeaderProviders(HttpUriRequestBase request) throws UR ); var mergedHeaders = new LinkedHashMap(); - if (httpHeaders != null) { - mergedHeaders.putAll(httpHeaders); - } for (var provider : providers) { mergedHeaders.putAll(provider.headersFor(context)); From f29e7d36ed2af96118f2e2d310112a29094dfd81 Mon Sep 17 00:00:00 2001 From: Dumitru Samuel Irinel Date: Wed, 10 Jun 2026 12:37:18 +0300 Subject: [PATCH 08/12] Use helper method to detect Content-Type at execution level .Add Javadoc for internal and protected methods --- .../impl/request/AbstractStreamingGet.java | 10 ++++++ .../impl/request/AbstractStreamingUpdate.java | 35 +++++++++++++++++-- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/java-client/src/main/java/energy/trolie/client/impl/request/AbstractStreamingGet.java b/java-client/src/main/java/energy/trolie/client/impl/request/AbstractStreamingGet.java index ea5d79e..94eaa8f 100644 --- a/java-client/src/main/java/energy/trolie/client/impl/request/AbstractStreamingGet.java +++ b/java-client/src/main/java/energy/trolie/client/impl/request/AbstractStreamingGet.java @@ -133,6 +133,16 @@ 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(), 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 f670129..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 @@ -15,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; @@ -163,13 +164,41 @@ protected OutputStream createRequestOutputStream() throws IOException, URISyntax 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(), - request.getFirstHeader(HttpHeaders.CONTENT_TYPE) != null - ? request.getFirstHeader(HttpHeaders.CONTENT_TYPE).getValue() - : null + contentType ); var mergedHeaders = new LinkedHashMap(); From a67d32e87b1ebd00bbe7f9bfd826e2b64c5625da Mon Sep 17 00:00:00 2001 From: Dumitru Samuel Irinel Date: Wed, 17 Jun 2026 13:49:35 +0300 Subject: [PATCH 09/12] Add example implementation for custom RequestHeaderProvider in TrolieClient --- .../RequestHeaderProviderUsageExample.java | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 java-client-examples/src/main/java/energy/trolie/client/examples/RequestHeaderProviderUsageExample.java diff --git a/java-client-examples/src/main/java/energy/trolie/client/examples/RequestHeaderProviderUsageExample.java b/java-client-examples/src/main/java/energy/trolie/client/examples/RequestHeaderProviderUsageExample.java new file mode 100644 index 0000000..26f84bb --- /dev/null +++ b/java-client-examples/src/main/java/energy/trolie/client/examples/RequestHeaderProviderUsageExample.java @@ -0,0 +1,113 @@ +package energy.trolie.client.examples; + +import energy.trolie.client.RequestHeaderProvider; +import energy.trolie.client.TrolieClient; +import energy.trolie.client.TrolieClientBuilder; +import energy.trolie.client.TrolieRequestContext; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.util.Base64; +import java.util.Map; +import java.util.UUID; + +/** + * This example demonstrates how to implement and register a custom {@link RequestHeaderProvider} + * with the {@link TrolieClientBuilder}. + *

+ * A {@link RequestHeaderProvider} allows the client to supply dynamic, request-time headers + * (like computed security signatures, authentication tokens, nonces, or timestamps) that may + * vary based on the request URI, method, or content type. + */ +public class RequestHeaderProviderUsageExample { + + private static final Logger logger = LoggerFactory.getLogger(RequestHeaderProviderUsageExample.class); + + private static TrolieClient initializeClient() { + // 1. Instantiate the custom header provider + RequestHeaderProvider dynamicHeaderProvider = new CustomTokenHeaderProvider("my-api-key-secret", "user-screen-name"); + + // 2. Build the TrolieClient with the provider registered + return new TrolieClientBuilder("http://localhost:8080", HttpClients.createDefault()) + // Register our custom dynamic header provider. + // It will be executed at request-time for every HTTP request the client performs, + // receiving the final request method, URI, and content type. + .addRequestHeaderProvider(dynamicHeaderProvider) + .build(); + } + + + public static void main(String[] args) { + logger.info("Initializing TrolieClient with dynamic request header provider..."); + + try (TrolieClient client = initializeClient()) { + logger.info("TrolieClient built successfully!"); + logger.info("The custom RequestHeaderProvider is now registered on the client."); + logger.info("Every outgoing request (GET, POST, PATCH, etc.) will dynamically trigger " + + "the RequestHeaderProvider, allowing it to inject fresh dynamic headers."); + } catch (Exception e) { + logger.error("Error creating or closing client", e); + } + } + + /** + * A sample implementation of {@link RequestHeaderProvider} that generates dynamic, + * request-specific authentication/authorization headers. + *

+ * For instance, if a server requires a dynamic HMAC signature over the request path + * and a unique nonce (such as a multifactor authentication token), this provider + * can compute it on-the-fly when the request is executed. + */ + private record CustomTokenHeaderProvider(String secretKey, String screenName) implements RequestHeaderProvider { + + @Override + public Map headersFor(TrolieRequestContext requestContext) { + // This is called by the SDK at request execution time. + // We have access to the final request method, URI, and content-type. + String method = requestContext.method(); + URI requestUri = requestContext.uri(); + String path = requestUri.getPath().toLowerCase(); + + // Generate a unique nonce + String nonce = UUID.randomUUID().toString(); + + // Capture the current timestamp + String timestamp = Instant.now().toString(); + + // Compute a digital signature over the request-specific details + String signature = generateSignature(nonce, timestamp, method, path, secretKey); + + logger.info("Computing headers for {} request to {}", method, path); + + // Return the key-value pairs representing the custom headers to add/override. + return Map.of( + "X-Request-Nonce", nonce, + "X-Request-Timestamp", timestamp, + "X-Request-Signature", signature, + "X-Request-ScreenName", screenName + ); + } + + private String generateSignature(String nonce, String timestamp, String method, String path, String secret) { + try { + // Combine request fields to form string to sign + String stringToSign = timestamp + "|" + method + "|" + nonce + "|" + path + "|" + secret; + + // Perform a sample cryptographic signature hash + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hashBytes = digest.digest(stringToSign.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(hashBytes); + } catch (NoSuchAlgorithmException e) { + logger.error("Hashing algorithm not found", e); + return "signature-failed"; + } + } + } +} + From 3e9cad994662e1212b91fed1d5da37f70436a2a5 Mon Sep 17 00:00:00 2001 From: Dumitru Samuel Irinel Date: Thu, 18 Jun 2026 19:12:58 +0300 Subject: [PATCH 10/12] Add implementation for SPP two factor authentication and create an example on how to use it. Modified readme.md file --- README.md | 38 ++++++ .../examples/PublishForecastProposals.java | 11 ++ .../RequestHeaderProviderUsageExample.java | 113 ------------------ .../client/examples/SppConnectionExample.java | 25 ++++ .../trolie/client/TrolieClientBuilder.java | 17 +++ .../client/spp/SppApiTokenHeaderProvider.java | 82 +++++++++++++ 6 files changed, 173 insertions(+), 113 deletions(-) delete mode 100644 java-client-examples/src/main/java/energy/trolie/client/examples/RequestHeaderProviderUsageExample.java create mode 100644 java-client-examples/src/main/java/energy/trolie/client/examples/SppConnectionExample.java create mode 100644 java-client/src/main/java/energy/trolie/client/spp/SppApiTokenHeaderProvider.java diff --git a/README.md b/README.md index df5ce0b..42a8aa3 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,44 @@ It is also built with Maven, specifically because it offers the broadest array of compatibility. Maven artifacts can easily be used by other build tools such as Gradle and SBT. +## Connecting to SPP TROLIE (SPP Two-Factor Authentication) + +For FERC 881 compliance, many Transmission Owners (TO) and neighboring +Reliability Coordinators (RC) must connect to Southwest Power Pool (SPP) +TROLIE systems. Doing so requires adherence to the +[SPP Two-Factor Authentication (TFA) Technical Specifications v1.4](https://spp.org/documents/75215/two-factor%20authentication%20technical%20specifications%20v1.4.pdf). + +The TROLIE Java Client SDK offers built-in support for SPP Authentication. +This handles the requirement to cryptographically sign every single outbound +request with a custom `X-SPP-API-Token` header using HTTP request metadata +(path, timestamp, nonce, and screen name) signed with an HMAC-SHA512 key. + +### Basic Setup with SPP TFA + +Configure the `TrolieClient` using your SPP-assigned screen name and Base64-encoded +API key directly in the builder: + +```java +import energy.trolie.client.TrolieClient; +import energy.trolie.client.TrolieClientBuilder; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import java.util.Base64; + +public class SppConnectionDemo { + public static void main(String[] args) throws Exception { + String base64ApiKey = Base64.getEncoder().encodeToString("your-spp-secret-key".getBytes()); + + try (TrolieClient client = new TrolieClientBuilder("https://trolie.spp.org", HttpClients.createDefault()) + .withSppAuthentication("YourSppScreenName", base64ApiKey) + .build()) { + + // The client automatically signs every outbound request + // with compliant X-SPP-API-Token signatures. + } + } +} +``` + ## Best Practices This library will include best practices around usage. This includes: diff --git a/java-client-examples/src/main/java/energy/trolie/client/examples/PublishForecastProposals.java b/java-client-examples/src/main/java/energy/trolie/client/examples/PublishForecastProposals.java index 9138d11..3888f26 100644 --- a/java-client-examples/src/main/java/energy/trolie/client/examples/PublishForecastProposals.java +++ b/java-client-examples/src/main/java/energy/trolie/client/examples/PublishForecastProposals.java @@ -17,12 +17,14 @@ import java.io.FileReader; import java.io.IOException; import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; import java.time.Duration; import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneId; import java.util.ArrayList; import java.util.Arrays; +import java.util.Base64; import java.util.List; import java.util.Map; import java.util.UUID; @@ -45,6 +47,15 @@ private static TrolieClient initializeClient() { // the test data provided in this example. .httpHeaders(Map.of( "X-TROLIE-Testing-Identity", "TO1")) + + // SPP Authentication (Optional): + // This configuration is required only when connecting to an SPP. + // It generates the mandatory X-SPP-API-Token header using an HMAC-SHA512 + // signature based on your SPP-provided Screen Name and Base64-encoded API Key + .withSppAuthentication( + "TestScreenName", + Base64.getEncoder().encodeToString("secretkey".getBytes(StandardCharsets.UTF_8)) + ) .build(); } diff --git a/java-client-examples/src/main/java/energy/trolie/client/examples/RequestHeaderProviderUsageExample.java b/java-client-examples/src/main/java/energy/trolie/client/examples/RequestHeaderProviderUsageExample.java deleted file mode 100644 index 26f84bb..0000000 --- a/java-client-examples/src/main/java/energy/trolie/client/examples/RequestHeaderProviderUsageExample.java +++ /dev/null @@ -1,113 +0,0 @@ -package energy.trolie.client.examples; - -import energy.trolie.client.RequestHeaderProvider; -import energy.trolie.client.TrolieClient; -import energy.trolie.client.TrolieClientBuilder; -import energy.trolie.client.TrolieRequestContext; -import org.apache.hc.client5.http.impl.classic.HttpClients; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.net.URI; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.time.Instant; -import java.util.Base64; -import java.util.Map; -import java.util.UUID; - -/** - * This example demonstrates how to implement and register a custom {@link RequestHeaderProvider} - * with the {@link TrolieClientBuilder}. - *

- * A {@link RequestHeaderProvider} allows the client to supply dynamic, request-time headers - * (like computed security signatures, authentication tokens, nonces, or timestamps) that may - * vary based on the request URI, method, or content type. - */ -public class RequestHeaderProviderUsageExample { - - private static final Logger logger = LoggerFactory.getLogger(RequestHeaderProviderUsageExample.class); - - private static TrolieClient initializeClient() { - // 1. Instantiate the custom header provider - RequestHeaderProvider dynamicHeaderProvider = new CustomTokenHeaderProvider("my-api-key-secret", "user-screen-name"); - - // 2. Build the TrolieClient with the provider registered - return new TrolieClientBuilder("http://localhost:8080", HttpClients.createDefault()) - // Register our custom dynamic header provider. - // It will be executed at request-time for every HTTP request the client performs, - // receiving the final request method, URI, and content type. - .addRequestHeaderProvider(dynamicHeaderProvider) - .build(); - } - - - public static void main(String[] args) { - logger.info("Initializing TrolieClient with dynamic request header provider..."); - - try (TrolieClient client = initializeClient()) { - logger.info("TrolieClient built successfully!"); - logger.info("The custom RequestHeaderProvider is now registered on the client."); - logger.info("Every outgoing request (GET, POST, PATCH, etc.) will dynamically trigger " + - "the RequestHeaderProvider, allowing it to inject fresh dynamic headers."); - } catch (Exception e) { - logger.error("Error creating or closing client", e); - } - } - - /** - * A sample implementation of {@link RequestHeaderProvider} that generates dynamic, - * request-specific authentication/authorization headers. - *

- * For instance, if a server requires a dynamic HMAC signature over the request path - * and a unique nonce (such as a multifactor authentication token), this provider - * can compute it on-the-fly when the request is executed. - */ - private record CustomTokenHeaderProvider(String secretKey, String screenName) implements RequestHeaderProvider { - - @Override - public Map headersFor(TrolieRequestContext requestContext) { - // This is called by the SDK at request execution time. - // We have access to the final request method, URI, and content-type. - String method = requestContext.method(); - URI requestUri = requestContext.uri(); - String path = requestUri.getPath().toLowerCase(); - - // Generate a unique nonce - String nonce = UUID.randomUUID().toString(); - - // Capture the current timestamp - String timestamp = Instant.now().toString(); - - // Compute a digital signature over the request-specific details - String signature = generateSignature(nonce, timestamp, method, path, secretKey); - - logger.info("Computing headers for {} request to {}", method, path); - - // Return the key-value pairs representing the custom headers to add/override. - return Map.of( - "X-Request-Nonce", nonce, - "X-Request-Timestamp", timestamp, - "X-Request-Signature", signature, - "X-Request-ScreenName", screenName - ); - } - - private String generateSignature(String nonce, String timestamp, String method, String path, String secret) { - try { - // Combine request fields to form string to sign - String stringToSign = timestamp + "|" + method + "|" + nonce + "|" + path + "|" + secret; - - // Perform a sample cryptographic signature hash - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - byte[] hashBytes = digest.digest(stringToSign.getBytes(StandardCharsets.UTF_8)); - return Base64.getEncoder().encodeToString(hashBytes); - } catch (NoSuchAlgorithmException e) { - logger.error("Hashing algorithm not found", e); - return "signature-failed"; - } - } - } -} - diff --git a/java-client-examples/src/main/java/energy/trolie/client/examples/SppConnectionExample.java b/java-client-examples/src/main/java/energy/trolie/client/examples/SppConnectionExample.java new file mode 100644 index 0000000..87d66bd --- /dev/null +++ b/java-client-examples/src/main/java/energy/trolie/client/examples/SppConnectionExample.java @@ -0,0 +1,25 @@ +package energy.trolie.client.examples; + +import energy.trolie.client.TrolieClient; +import energy.trolie.client.TrolieClientBuilder; +import org.apache.hc.client5.http.impl.classic.HttpClients; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +public class SppConnectionExample { + + public static void main(String[] args) throws IOException { + try (TrolieClient client = new TrolieClientBuilder( + "http://localhost:8080", + HttpClients.createDefault()) + .withSppAuthentication( + "TestScreenName", + Base64.getEncoder().encodeToString("secretkey".getBytes(StandardCharsets.UTF_8))) + .build()) { + + // Client is now ready to interact with SPP system + } + } +} \ No newline at end of file diff --git a/java-client/src/main/java/energy/trolie/client/TrolieClientBuilder.java b/java-client/src/main/java/energy/trolie/client/TrolieClientBuilder.java index 364685f..4acf232 100644 --- a/java-client/src/main/java/energy/trolie/client/TrolieClientBuilder.java +++ b/java-client/src/main/java/energy/trolie/client/TrolieClientBuilder.java @@ -9,10 +9,12 @@ import energy.trolie.client.request.operatingsnapshots.ForecastSnapshotSubscribedReceiver; import energy.trolie.client.request.operatingsnapshots.SeasonalSnapshotSubscribedReceiver; import energy.trolie.client.request.operatingsnapshots.RealTimeSnapshotSubscribedReceiver; +import energy.trolie.client.spp.SppApiTokenHeaderProvider; import org.apache.hc.client5.http.config.RequestConfig; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; import org.apache.hc.client5.http.impl.classic.HttpClients; +import java.time.Clock; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -232,6 +234,21 @@ public TrolieClientBuilder seasonalRatingsPollMs(int seasonalRatingsPollMs) { return this; } + /** + * Configures this client to authenticate with an SPP system + * using the SPP Two-Factor Authentication (TFA) protocol. + * + * @param screenName the SPP-assigned screen name + * @param apiKey the Base64-encoded API key assigned by SPP + * @return this builder + * @see SppApiTokenHeaderProvider + */ + public TrolieClientBuilder withSppAuthentication(String screenName, String apiKey) { + return addRequestHeaderProvider( + new SppApiTokenHeaderProvider(screenName, apiKey, Clock.systemUTC()) + ); + } + /** * Construct a new client * @return new client 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..12ce4f2 --- /dev/null +++ b/java-client/src/main/java/energy/trolie/client/spp/SppApiTokenHeaderProvider.java @@ -0,0 +1,82 @@ +package energy.trolie.client.spp; + +import energy.trolie.client.RequestHeaderProvider; +import energy.trolie.client.TrolieRequestContext; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.codec.digest.HmacAlgorithms; +import org.apache.commons.codec.digest.HmacUtils; + +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 = new HmacUtils(HmacAlgorithms.HMAC_SHA_512, decodedApiKey).hmac(stringToSign); + 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 From 84742b2bbb3ee48c23a70bbe7a7da7249ff2c460 Mon Sep 17 00:00:00 2001 From: Dumitru Samuel Irinel Date: Thu, 18 Jun 2026 19:35:01 +0300 Subject: [PATCH 11/12] Added unit test for SPP authentication header generation --- .../examples/PublishForecastProposals.java | 2 +- .../energy/trolie/client/TrolieClientIT.java | 65 +++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/java-client-examples/src/main/java/energy/trolie/client/examples/PublishForecastProposals.java b/java-client-examples/src/main/java/energy/trolie/client/examples/PublishForecastProposals.java index 3888f26..3d42f43 100644 --- a/java-client-examples/src/main/java/energy/trolie/client/examples/PublishForecastProposals.java +++ b/java-client-examples/src/main/java/energy/trolie/client/examples/PublishForecastProposals.java @@ -49,7 +49,7 @@ private static TrolieClient initializeClient() { "X-TROLIE-Testing-Identity", "TO1")) // SPP Authentication (Optional): - // This configuration is required only when connecting to an SPP. + // This configuration is required only when connecting to SPP. // It generates the mandatory X-SPP-API-Token header using an HMAC-SHA512 // signature based on your SPP-provided Screen Name and Base64-encoded API Key .withSppAuthentication( 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 efcf77d..486e557 100644 --- a/java-client/src/test/java/energy/trolie/client/TrolieClientIT.java +++ b/java-client/src/test/java/energy/trolie/client/TrolieClientIT.java @@ -33,7 +33,10 @@ 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.commons.codec.digest.HmacAlgorithms; +import org.apache.commons.codec.digest.HmacUtils; import org.apache.hc.client5.http.HttpHostConnectException; import org.apache.hc.client5.http.classic.HttpClient; import org.apache.hc.client5.http.classic.methods.HttpGet; @@ -69,7 +72,12 @@ import java.io.PipedOutputStream; import java.net.InetAddress; import java.net.ServerSocket; +import java.net.URI; +import java.nio.charset.StandardCharsets; +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; @@ -81,6 +89,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; @@ -1829,6 +1838,62 @@ void testDynamicRequestHeaderProvider() throws IOException { } } + @Test + void testSppApiTokenHeaderProvider_generatesCorrectHeader() { + // 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 = new HmacUtils(HmacAlgorithms.HMAC_SHA_512, rawApiKey.getBytes(StandardCharsets.UTF_8)) + .hmac(expectedStringToSign); + 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(); From a9f6ddc246006afae8b4428f5dee41d745dcef34 Mon Sep 17 00:00:00 2001 From: Dumitru Samuel Irinel Date: Thu, 18 Jun 2026 19:53:52 +0300 Subject: [PATCH 12/12] replace commons-codec with java standard cryptography library --- .../client/spp/SppApiTokenHeaderProvider.java | 17 ++++++++++++++--- .../energy/trolie/client/TrolieClientIT.java | 15 ++++++++++----- 2 files changed, 24 insertions(+), 8 deletions(-) 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 index 12ce4f2..36ad9d7 100644 --- a/java-client/src/main/java/energy/trolie/client/spp/SppApiTokenHeaderProvider.java +++ b/java-client/src/main/java/energy/trolie/client/spp/SppApiTokenHeaderProvider.java @@ -3,9 +3,12 @@ import energy.trolie.client.RequestHeaderProvider; import energy.trolie.client.TrolieRequestContext; import lombok.extern.slf4j.Slf4j; -import org.apache.commons.codec.digest.HmacAlgorithms; -import org.apache.commons.codec.digest.HmacUtils; +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; @@ -68,7 +71,15 @@ public Map headersFor(TrolieRequestContext context) { log.debug("Creating SPP API token with string to sign: {}", stringToSign); // 5. Compute HMAC-SHA512 and Base64-encode - byte[] hmacBytes = new HmacUtils(HmacAlgorithms.HMAC_SHA_512, decodedApiKey).hmac(stringToSign); + 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); 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 486e557..8dbefa2 100644 --- a/java-client/src/test/java/energy/trolie/client/TrolieClientIT.java +++ b/java-client/src/test/java/energy/trolie/client/TrolieClientIT.java @@ -35,8 +35,6 @@ import energy.trolie.client.request.ratingproposals.RealTimeRatingProposalUpdate; import energy.trolie.client.spp.SppApiTokenHeaderProvider; import lombok.extern.slf4j.Slf4j; -import org.apache.commons.codec.digest.HmacAlgorithms; -import org.apache.commons.codec.digest.HmacUtils; import org.apache.hc.client5.http.HttpHostConnectException; import org.apache.hc.client5.http.classic.HttpClient; import org.apache.hc.client5.http.classic.methods.HttpGet; @@ -66,6 +64,8 @@ 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; @@ -74,6 +74,8 @@ 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; @@ -1839,7 +1841,7 @@ void testDynamicRequestHeaderProvider() throws IOException { } @Test - void testSppApiTokenHeaderProvider_generatesCorrectHeader() { + void testSppApiTokenHeaderProvider_generatesCorrectHeader() throws NoSuchAlgorithmException, InvalidKeyException { // Arrange String screenName = "TestScreenName"; String rawApiKey = "my-secret-api-key-987654321"; @@ -1887,8 +1889,11 @@ void testSppApiTokenHeaderProvider_generatesCorrectHeader() { String lowerPath = "/api/v1/ratings"; String expectedStringToSign = nonceStr + expectedTimestamp + lowerScreenName + lowerPath; - byte[] expectedHmacBytes = new HmacUtils(HmacAlgorithms.HMAC_SHA_512, rawApiKey.getBytes(StandardCharsets.UTF_8)) - .hmac(expectedStringToSign); + 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");