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..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 @@ -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 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/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/RequestHeaderProvider.java b/java-client/src/main/java/energy/trolie/client/RequestHeaderProvider.java new file mode 100644 index 0000000..7fc5b89 --- /dev/null +++ b/java-client/src/main/java/energy/trolie/client/RequestHeaderProvider.java @@ -0,0 +1,18 @@ +package energy.trolie.client; + +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 0700e25..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,11 +9,15 @@ 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; import java.util.Map; /** @@ -50,6 +54,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 +139,43 @@ public TrolieClientBuilder httpHeaders(Map httpHeaders) { return this; } + /** + * Adds a request header provider that can generate additional headers for each request execution. + * 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 + */ + 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 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 + */ + 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. @@ -192,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 @@ -216,7 +273,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..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 @@ -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,30 @@ protected boolean handleResponse(ClassicHttpResponse response) { return false; } + + /** + * Applies headers from all registered providers to the given GET request. + *

+ * Since GET requests generally do not carry a body, this method initializes + * the {@link TrolieRequestContext} with a {@code null} content type, allowing + * providers to apply relevant headers based on the request metadata. + * + * @param request the HTTP GET request to which headers will be applied + * @throws URISyntaxException if the request URI cannot be parsed + */ + protected void applyRequestHeaderProviders(HttpGet request) throws URISyntaxException { + + TrolieRequestContext context = new TrolieRequestContext(request.getMethod(), + request.getUri(), + null); + var mergedHeaders = new LinkedHashMap(); + + for (var provider : providers) { + mergedHeaders.putAll(provider.headersFor(context)); + } + + mergedHeaders.forEach(request::setHeader); + } protected HttpGet createRequest() throws URISyntaxException { HttpGet get = new HttpGet(getFullPath()); @@ -132,7 +163,7 @@ protected HttpGet createRequest() throws URISyntaxException { if (httpHeaders != null && !httpHeaders.isEmpty()) { httpHeaders.forEach(get::addHeader); } - + get.setConfig(requestConfig); return get; } @@ -141,6 +172,9 @@ public void executeRequest() { try { lastRequestFailed = false; HttpGet get = createRequest(); + if (providers != null && !providers.isEmpty()) { + applyRequestHeaderProviders(get); + } httpClient.execute(host.getHost(), get, createResponseHandler()); } catch (IOException e) { diff --git a/java-client/src/main/java/energy/trolie/client/impl/request/AbstractStreamingSubscribedGet.java b/java-client/src/main/java/energy/trolie/client/impl/request/AbstractStreamingSubscribedGet.java index 40e8d6f..0553973 100644 --- a/java-client/src/main/java/energy/trolie/client/impl/request/AbstractStreamingSubscribedGet.java +++ b/java-client/src/main/java/energy/trolie/client/impl/request/AbstractStreamingSubscribedGet.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import energy.trolie.client.ETagStore; +import energy.trolie.client.RequestHeaderProvider; import energy.trolie.client.StreamingSubscribedResponseReceiver; import energy.trolie.client.TrolieHost; import energy.trolie.client.exception.StreamingGetHandlingException; @@ -16,6 +17,7 @@ import org.slf4j.LoggerFactory; import java.net.URISyntaxException; +import java.util.List; import java.util.Map; import java.util.concurrent.Callable; import java.util.concurrent.Future; @@ -49,10 +51,11 @@ protected AbstractStreamingSubscribedGet( int bufferSize, ObjectMapper objectMapper, Map httpHeaders, + List providers, int pollingRateMillis, T receiver, ETagStore eTagStore) { - super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, receiver); + super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, providers, receiver); this.pollingRateMillis = pollingRateMillis; this.eTagStore = eTagStore; } diff --git a/java-client/src/main/java/energy/trolie/client/impl/request/AbstractStreamingUpdate.java b/java-client/src/main/java/energy/trolie/client/impl/request/AbstractStreamingUpdate.java index 9c1acda..672a9a4 100644 --- a/java-client/src/main/java/energy/trolie/client/impl/request/AbstractStreamingUpdate.java +++ b/java-client/src/main/java/energy/trolie/client/impl/request/AbstractStreamingUpdate.java @@ -1,8 +1,10 @@ package energy.trolie.client.impl.request; import com.fasterxml.jackson.databind.ObjectMapper; +import energy.trolie.client.RequestHeaderProvider; import energy.trolie.client.StreamingUpdate; import energy.trolie.client.TrolieHost; +import energy.trolie.client.TrolieRequestContext; import energy.trolie.client.exception.TrolieException; import energy.trolie.client.exception.TrolieServerException; import lombok.AllArgsConstructor; @@ -13,6 +15,7 @@ import org.apache.hc.client5.http.entity.GzipCompressingEntity; import org.apache.hc.client5.http.impl.classic.AbstractHttpClientResponseHandler; import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.Header; import org.apache.hc.core5.http.HttpEntity; import org.apache.hc.core5.http.HttpHeaders; import org.apache.hc.core5.http.io.entity.InputStreamEntity; @@ -23,6 +26,9 @@ import java.io.OutputStream; import java.io.PipedInputStream; import java.io.PipedOutputStream; +import java.net.URISyntaxException; +import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.Callable; import java.util.concurrent.ExecutionException; @@ -50,9 +56,10 @@ public abstract class AbstractStreamingUpdate implements StreamingUpdate { Future responseFuture; Map httpHeaders; + List providers; protected AbstractStreamingUpdate(HttpClient httpClient, TrolieHost host, RequestConfig requestConfig, - int bufferSize, ObjectMapper objectMapper, Map httpHeaders) { + int bufferSize, ObjectMapper objectMapper, Map httpHeaders, List providers) { super(); this.httpClient = httpClient; this.host = host; @@ -62,6 +69,7 @@ protected AbstractStreamingUpdate(HttpClient httpClient, TrolieHost host, Reques this.bufferSize = bufferSize; this.objectMapper = objectMapper; this.httpHeaders = httpHeaders; + this.providers = providers; } /** @@ -122,9 +130,9 @@ protected void handleWriteError(Exception e) { * initiate the request and pipe an output stream to the request entity * * @return - * @throws IOException + * @throws IOException URISyntaxException */ - protected OutputStream createRequestOutputStream() throws IOException { + protected OutputStream createRequestOutputStream() throws IOException, URISyntaxException { //they probably already set these parameters, but may as well make sure HttpUriRequestBase request = getRequest(); @@ -136,6 +144,10 @@ protected OutputStream createRequestOutputStream() throws IOException { request.setConfig(this.requestConfig); + if (providers != null && !providers.isEmpty()) { + applyRequestHeaderProviders(request); + } + //create a request entity we can write into from a stream PipedOutputStream pipedOutputStream = new PipedOutputStream(); PipedInputStream pipedInputStream = new PipedInputStream(pipedOutputStream, bufferSize); @@ -152,6 +164,52 @@ protected OutputStream createRequestOutputStream() throws IOException { return outputStream; } + /** + * Determines the Content-Type for the request context. + *

+ * This method prioritizes any existing Content-Type header already set on the request. + * If none is found, it falls back to the {@link #getContentType()} provided by this instance. + * + * @param request the current HTTP request + * @return the Content-Type as a string, or {@code null} if none is defined + */ + private String determineContentType(HttpUriRequestBase request) { + Header header = request.getFirstHeader(HttpHeaders.CONTENT_TYPE); + if (header != null) { + return header.getValue(); + } + + return getContentType() != null ? getContentType().toString() : null; + } + + /** + * Applies headers from all registered providers to the given request. + *

+ * This method builds a {@link TrolieRequestContext} based on the request's current state, + * allowing providers to inspect the request and contribute necessary headers. + * The resulting headers are then merged and applied to the request instance. + * + * @param request the HTTP request to which headers will be applied + * @throws URISyntaxException if the request URI cannot be parsed + */ + protected void applyRequestHeaderProviders(HttpUriRequestBase request) throws URISyntaxException { + String contentType = determineContentType(request); + + var context = new TrolieRequestContext( + request.getMethod(), + request.getUri(), + contentType + ); + + var mergedHeaders = new LinkedHashMap(); + + for (var provider : providers) { + mergedHeaders.putAll(provider.headersFor(context)); + } + + mergedHeaders.forEach(request::setHeader); + } + /** * Finalize the stream and check for errors on the request thread * diff --git a/java-client/src/main/java/energy/trolie/client/impl/request/monitoringsets/DefaultMonitoringSetRequest.java b/java-client/src/main/java/energy/trolie/client/impl/request/monitoringsets/DefaultMonitoringSetRequest.java index d7757bd..f2c174e 100644 --- a/java-client/src/main/java/energy/trolie/client/impl/request/monitoringsets/DefaultMonitoringSetRequest.java +++ b/java-client/src/main/java/energy/trolie/client/impl/request/monitoringsets/DefaultMonitoringSetRequest.java @@ -1,6 +1,7 @@ package energy.trolie.client.impl.request.monitoringsets; import com.fasterxml.jackson.databind.ObjectMapper; +import energy.trolie.client.RequestHeaderProvider; import energy.trolie.client.TrolieApiConstants; import energy.trolie.client.TrolieHost; import energy.trolie.client.impl.request.AbstractStreamingGet; @@ -9,6 +10,7 @@ import org.apache.hc.client5.http.config.RequestConfig; import java.io.InputStream; +import java.util.List; import java.util.Map; /** @@ -24,9 +26,10 @@ public DefaultMonitoringSetRequest( int bufferSize, ObjectMapper objectMapper, Map httpHeaders, + List providers, MonitoringSetsReceiver receiver) { - super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, receiver); + super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, providers, receiver); } @Override diff --git a/java-client/src/main/java/energy/trolie/client/impl/request/monitoringsets/DefaultMonitoringSetSubscribedRequest.java b/java-client/src/main/java/energy/trolie/client/impl/request/monitoringsets/DefaultMonitoringSetSubscribedRequest.java index 081d5d7..4246e67 100644 --- a/java-client/src/main/java/energy/trolie/client/impl/request/monitoringsets/DefaultMonitoringSetSubscribedRequest.java +++ b/java-client/src/main/java/energy/trolie/client/impl/request/monitoringsets/DefaultMonitoringSetSubscribedRequest.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import energy.trolie.client.ETagStore; +import energy.trolie.client.RequestHeaderProvider; import energy.trolie.client.TrolieApiConstants; import energy.trolie.client.TrolieHost; import energy.trolie.client.impl.request.AbstractStreamingSubscribedGet; @@ -10,6 +11,7 @@ import org.apache.hc.client5.http.config.RequestConfig; import java.io.InputStream; +import java.util.List; import java.util.Map; /** @@ -25,10 +27,11 @@ public DefaultMonitoringSetSubscribedRequest( int bufferSize, ObjectMapper objectMapper, Map httpHeaders, + List providers, int pollingRateMillis, MonitoringSetsSubscribedReceiver receiver, ETagStore eTagStore) { - super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, pollingRateMillis, + super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, providers, pollingRateMillis, receiver, eTagStore); } diff --git a/java-client/src/main/java/energy/trolie/client/impl/request/monitoringsets/MonitoringSetsRequest.java b/java-client/src/main/java/energy/trolie/client/impl/request/monitoringsets/MonitoringSetsRequest.java index 0c4b8d4..7318fda 100644 --- a/java-client/src/main/java/energy/trolie/client/impl/request/monitoringsets/MonitoringSetsRequest.java +++ b/java-client/src/main/java/energy/trolie/client/impl/request/monitoringsets/MonitoringSetsRequest.java @@ -1,6 +1,7 @@ package energy.trolie.client.impl.request.monitoringsets; import com.fasterxml.jackson.databind.ObjectMapper; +import energy.trolie.client.RequestHeaderProvider; import energy.trolie.client.TrolieApiConstants; import energy.trolie.client.TrolieHost; import energy.trolie.client.impl.request.AbstractStreamingGet; @@ -12,6 +13,7 @@ import java.io.InputStream; import java.net.URISyntaxException; +import java.util.List; import java.util.Map; /** @@ -28,10 +30,11 @@ public MonitoringSetsRequest( int bufferSize, ObjectMapper objectMapper, Map httpHeaders, + List providers, MonitoringSetsReceiver receiver, String monitoringSet) { - super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, receiver); + super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, providers, receiver); if (monitoringSet == null || monitoringSet.isBlank()) { throw new IllegalArgumentException("Monitoring set name cannot be null or blank"); diff --git a/java-client/src/main/java/energy/trolie/client/impl/request/monitoringsets/MonitoringSetsSubscribedRequest.java b/java-client/src/main/java/energy/trolie/client/impl/request/monitoringsets/MonitoringSetsSubscribedRequest.java index 9a165b0..02178f7 100644 --- a/java-client/src/main/java/energy/trolie/client/impl/request/monitoringsets/MonitoringSetsSubscribedRequest.java +++ b/java-client/src/main/java/energy/trolie/client/impl/request/monitoringsets/MonitoringSetsSubscribedRequest.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import energy.trolie.client.ETagStore; +import energy.trolie.client.RequestHeaderProvider; import energy.trolie.client.TrolieApiConstants; import energy.trolie.client.TrolieHost; import energy.trolie.client.impl.request.AbstractStreamingSubscribedGet; @@ -13,6 +14,7 @@ import java.io.InputStream; import java.net.URISyntaxException; +import java.util.List; import java.util.Map; /** @@ -29,12 +31,13 @@ public MonitoringSetsSubscribedRequest( int bufferSize, ObjectMapper objectMapper, Map httpHeaders, + List providers, int pollingRateMillis, MonitoringSetsSubscribedReceiver receiver, ETagStore eTagStore, String monitoringSet) { - super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, pollingRateMillis, + super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, providers, pollingRateMillis, receiver, eTagStore); if (monitoringSet == null || monitoringSet.isBlank()) { diff --git a/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/ForecastSnapshotRequest.java b/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/ForecastSnapshotRequest.java index 2869650..5aac506 100644 --- a/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/ForecastSnapshotRequest.java +++ b/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/ForecastSnapshotRequest.java @@ -1,6 +1,7 @@ package energy.trolie.client.impl.request.operatingsnapshots; import com.fasterxml.jackson.databind.ObjectMapper; +import energy.trolie.client.RequestHeaderProvider; import energy.trolie.client.TrolieApiConstants; import energy.trolie.client.TrolieHost; import energy.trolie.client.impl.request.AbstractStreamingGet; @@ -13,6 +14,7 @@ import java.io.InputStream; import java.net.URISyntaxException; import java.time.Instant; +import java.util.List; import java.util.Map; /** @@ -32,13 +34,14 @@ public ForecastSnapshotRequest( int bufferSize, ObjectMapper objectMapper, Map httpHeaders, + List providers, ForecastSnapshotReceiver receiver, String monitoringSet, String resourceId, Instant offsetPeriodStart, Instant periodEnd) { - super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, receiver); + super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, providers, receiver); this.monitoringSet = monitoringSet; this.resourceId = resourceId; this.offsetPeriodStart = offsetPeriodStart; diff --git a/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/ForecastSnapshotSubscribedRequest.java b/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/ForecastSnapshotSubscribedRequest.java index 1fa2749..0e99038 100644 --- a/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/ForecastSnapshotSubscribedRequest.java +++ b/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/ForecastSnapshotSubscribedRequest.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import energy.trolie.client.ETagStore; +import energy.trolie.client.RequestHeaderProvider; import energy.trolie.client.TrolieApiConstants; import energy.trolie.client.TrolieHost; import energy.trolie.client.impl.request.AbstractStreamingSubscribedGet; @@ -13,6 +14,7 @@ import java.io.InputStream; import java.net.URISyntaxException; +import java.util.List; import java.util.Map; /** @@ -29,12 +31,13 @@ public ForecastSnapshotSubscribedRequest( int bufferSize, ObjectMapper objectMapper, Map httpHeaders, + List providers, int pollingRateMillis, ForecastSnapshotSubscribedReceiver receiver, ETagStore eTagStore, String monitoringSet) { - super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, pollingRateMillis, + super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, providers, pollingRateMillis, receiver, eTagStore); this.monitoringSet = monitoringSet; } diff --git a/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/RealTimeSnapshotRequest.java b/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/RealTimeSnapshotRequest.java index 23f0d0e..ccedd3b 100644 --- a/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/RealTimeSnapshotRequest.java +++ b/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/RealTimeSnapshotRequest.java @@ -1,6 +1,7 @@ package energy.trolie.client.impl.request.operatingsnapshots; import com.fasterxml.jackson.databind.ObjectMapper; +import energy.trolie.client.RequestHeaderProvider; import energy.trolie.client.TrolieApiConstants; import energy.trolie.client.TrolieHost; import energy.trolie.client.impl.request.AbstractStreamingGet; @@ -12,6 +13,7 @@ import java.io.InputStream; import java.net.URISyntaxException; +import java.util.List; import java.util.Map; /** @@ -29,11 +31,12 @@ public RealTimeSnapshotRequest( int bufferSize, ObjectMapper objectMapper, Map httpHeaders, + List providers, RealTimeSnapshotReceiver receiver, String monitoringSet, String resourceId) { - super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, receiver); + super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, providers, receiver); this.monitoringSet = monitoringSet; this.resourceId = resourceId; } diff --git a/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/RealTimeSnapshotSubscribedRequest.java b/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/RealTimeSnapshotSubscribedRequest.java index b696737..2e62d2f 100644 --- a/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/RealTimeSnapshotSubscribedRequest.java +++ b/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/RealTimeSnapshotSubscribedRequest.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import energy.trolie.client.ETagStore; +import energy.trolie.client.RequestHeaderProvider; import energy.trolie.client.TrolieApiConstants; import energy.trolie.client.TrolieHost; import energy.trolie.client.impl.request.AbstractStreamingSubscribedGet; @@ -13,6 +14,7 @@ import java.io.InputStream; import java.net.URISyntaxException; +import java.util.List; import java.util.Map; /** @@ -30,13 +32,14 @@ public RealTimeSnapshotSubscribedRequest( int bufferSize, ObjectMapper objectMapper, Map httpHeaders, + List providers, int pollingRateMillis, RealTimeSnapshotSubscribedReceiver receiver, ETagStore eTagStore, String monitoringSet, String resourceId) { - super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, + super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, providers, pollingRateMillis, receiver, eTagStore); this.monitoringSet = monitoringSet; this.resourceId = resourceId; diff --git a/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/RegionalForecastSnapshotRequest.java b/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/RegionalForecastSnapshotRequest.java index 2677362..6881dfd 100644 --- a/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/RegionalForecastSnapshotRequest.java +++ b/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/RegionalForecastSnapshotRequest.java @@ -1,6 +1,7 @@ package energy.trolie.client.impl.request.operatingsnapshots; import com.fasterxml.jackson.databind.ObjectMapper; +import energy.trolie.client.RequestHeaderProvider; import energy.trolie.client.TrolieApiConstants; import energy.trolie.client.TrolieHost; import energy.trolie.client.request.operatingsnapshots.ForecastSnapshotReceiver; @@ -8,6 +9,7 @@ import org.apache.hc.client5.http.config.RequestConfig; import java.time.Instant; +import java.util.List; import java.util.Map; /** @@ -22,13 +24,14 @@ public RegionalForecastSnapshotRequest( int bufferSize, ObjectMapper objectMapper, Map httpHeaders, + List providers, ForecastSnapshotReceiver receiver, String monitoringSet, String resourceId, Instant offsetPeriodStart, Instant periodEnd) { - super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, receiver, + super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, providers, receiver, monitoringSet, resourceId, offsetPeriodStart, periodEnd); } diff --git a/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/RegionalForecastSubscribedSnapshotRequest.java b/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/RegionalForecastSubscribedSnapshotRequest.java index c653112..4f467ed 100644 --- a/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/RegionalForecastSubscribedSnapshotRequest.java +++ b/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/RegionalForecastSubscribedSnapshotRequest.java @@ -2,12 +2,14 @@ import com.fasterxml.jackson.databind.ObjectMapper; import energy.trolie.client.ETagStore; +import energy.trolie.client.RequestHeaderProvider; import energy.trolie.client.TrolieApiConstants; import energy.trolie.client.TrolieHost; import energy.trolie.client.request.operatingsnapshots.ForecastSnapshotSubscribedReceiver; import org.apache.hc.client5.http.classic.HttpClient; import org.apache.hc.client5.http.config.RequestConfig; +import java.util.List; import java.util.Map; /** @@ -22,12 +24,13 @@ public RegionalForecastSubscribedSnapshotRequest( int bufferSize, ObjectMapper objectMapper, Map httpHeaders, + List providers, int pollingRateMillis, ForecastSnapshotSubscribedReceiver receiver, ETagStore eTagStore, String monitoringSet) { - super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, pollingRateMillis, receiver, + super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, providers, pollingRateMillis, receiver, eTagStore, monitoringSet); } diff --git a/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/RegionalRealTimeSnapshotRequest.java b/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/RegionalRealTimeSnapshotRequest.java index e1308c1..223b078 100644 --- a/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/RegionalRealTimeSnapshotRequest.java +++ b/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/RegionalRealTimeSnapshotRequest.java @@ -1,12 +1,14 @@ package energy.trolie.client.impl.request.operatingsnapshots; import com.fasterxml.jackson.databind.ObjectMapper; +import energy.trolie.client.RequestHeaderProvider; import energy.trolie.client.TrolieApiConstants; import energy.trolie.client.TrolieHost; import energy.trolie.client.request.operatingsnapshots.RealTimeSnapshotReceiver; import org.apache.hc.client5.http.classic.HttpClient; import org.apache.hc.client5.http.config.RequestConfig; +import java.util.List; import java.util.Map; /** @@ -21,11 +23,12 @@ public RegionalRealTimeSnapshotRequest( int bufferSize, ObjectMapper objectMapper, Map httpHeaders, + List providers, RealTimeSnapshotReceiver receiver, String monitoringSet, String resourceId) { - super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, receiver, + super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, providers, receiver, monitoringSet, resourceId); } diff --git a/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/RegionalRealTimeSnapshotSubscribedRequest.java b/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/RegionalRealTimeSnapshotSubscribedRequest.java index 03c6301..45789f5 100644 --- a/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/RegionalRealTimeSnapshotSubscribedRequest.java +++ b/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/RegionalRealTimeSnapshotSubscribedRequest.java @@ -2,12 +2,14 @@ import com.fasterxml.jackson.databind.ObjectMapper; import energy.trolie.client.ETagStore; +import energy.trolie.client.RequestHeaderProvider; import energy.trolie.client.TrolieApiConstants; import energy.trolie.client.TrolieHost; import energy.trolie.client.request.operatingsnapshots.RealTimeSnapshotSubscribedReceiver; import org.apache.hc.client5.http.classic.HttpClient; import org.apache.hc.client5.http.config.RequestConfig; +import java.util.List; import java.util.Map; /** @@ -22,12 +24,13 @@ public RegionalRealTimeSnapshotSubscribedRequest( int bufferSize, ObjectMapper objectMapper, Map httpHeaders, + List providers, int pollingRateMillis, RealTimeSnapshotSubscribedReceiver receiver, ETagStore eTagStore, String monitoringSet) { - super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, pollingRateMillis, receiver, + super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, providers, pollingRateMillis, receiver, eTagStore, monitoringSet, null); } diff --git a/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/SeasonalSnapshotRequest.java b/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/SeasonalSnapshotRequest.java index af51080..3942990 100644 --- a/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/SeasonalSnapshotRequest.java +++ b/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/SeasonalSnapshotRequest.java @@ -1,6 +1,7 @@ package energy.trolie.client.impl.request.operatingsnapshots; import com.fasterxml.jackson.databind.ObjectMapper; +import energy.trolie.client.RequestHeaderProvider; import energy.trolie.client.TrolieApiConstants; import energy.trolie.client.TrolieHost; import energy.trolie.client.impl.request.AbstractStreamingGet; @@ -12,6 +13,7 @@ import java.io.InputStream; import java.net.URISyntaxException; +import java.util.List; import java.util.Map; /** @@ -29,11 +31,12 @@ public SeasonalSnapshotRequest( int bufferSize, ObjectMapper objectMapper, Map httpHeaders, + List providers, SeasonalSnapshotReceiver receiver, // seasonal String monitoringSet, String resourceId) { - super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, receiver); + super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, providers, receiver); this.monitoringSet = monitoringSet; this.resourceId = resourceId; } diff --git a/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/SeasonalSnapshotSubscribedRequest.java b/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/SeasonalSnapshotSubscribedRequest.java index c44169f..570c4d5 100644 --- a/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/SeasonalSnapshotSubscribedRequest.java +++ b/java-client/src/main/java/energy/trolie/client/impl/request/operatingsnapshots/SeasonalSnapshotSubscribedRequest.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import energy.trolie.client.ETagStore; +import energy.trolie.client.RequestHeaderProvider; import energy.trolie.client.TrolieApiConstants; import energy.trolie.client.TrolieHost; import energy.trolie.client.impl.request.AbstractStreamingSubscribedGet; @@ -13,6 +14,7 @@ import java.io.InputStream; import java.net.URISyntaxException; +import java.util.List; import java.util.Map; /** @@ -29,12 +31,13 @@ public SeasonalSnapshotSubscribedRequest( int bufferSize, ObjectMapper objectMapper, Map httpHeaders, + List providers, int pollingRateMillis, SeasonalSnapshotSubscribedReceiver receiver, ETagStore eTagStore, String monitoringSet) { - super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, pollingRateMillis, + super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, providers, pollingRateMillis, receiver, eTagStore); this.monitoringSet = monitoringSet; } diff --git a/java-client/src/main/java/energy/trolie/client/request/ratingproposals/ForecastRatingProposalUpdate.java b/java-client/src/main/java/energy/trolie/client/request/ratingproposals/ForecastRatingProposalUpdate.java index ff47de5..87299a1 100644 --- a/java-client/src/main/java/energy/trolie/client/request/ratingproposals/ForecastRatingProposalUpdate.java +++ b/java-client/src/main/java/energy/trolie/client/request/ratingproposals/ForecastRatingProposalUpdate.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.databind.ObjectMapper; +import energy.trolie.client.RequestHeaderProvider; import energy.trolie.client.TrolieApiConstants; import energy.trolie.client.TrolieHost; import energy.trolie.client.exception.TrolieException; @@ -21,6 +22,7 @@ import org.slf4j.LoggerFactory; import java.time.Instant; +import java.util.List; import java.util.Map; import java.util.function.Function; @@ -48,12 +50,13 @@ public class ForecastRatingProposalUpdate extends AbstractStreamingUpdate httpHeaders, - int defaultIntervalMinutes) { - super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders); + List providers, int defaultIntervalMinutes) { + super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeaders, providers); this.defaultIntervalMinutes = defaultIntervalMinutes; } diff --git a/java-client/src/main/java/energy/trolie/client/request/ratingproposals/RealTimeRatingProposalUpdate.java b/java-client/src/main/java/energy/trolie/client/request/ratingproposals/RealTimeRatingProposalUpdate.java index f50d34a..3a69bfe 100644 --- a/java-client/src/main/java/energy/trolie/client/request/ratingproposals/RealTimeRatingProposalUpdate.java +++ b/java-client/src/main/java/energy/trolie/client/request/ratingproposals/RealTimeRatingProposalUpdate.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.databind.ObjectMapper; +import energy.trolie.client.RequestHeaderProvider; import energy.trolie.client.TrolieApiConstants; import energy.trolie.client.TrolieHost; import energy.trolie.client.exception.TrolieException; @@ -18,6 +19,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.List; import java.util.Map; import java.util.function.Function; @@ -42,10 +44,11 @@ public class RealTimeRatingProposalUpdate extends AbstractStreamingUpdate httpHeader) { - super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeader); + int bufferSize, ObjectMapper objectMapper, Map httpHeader, List providers) { + super(httpClient, host, requestConfig, bufferSize, objectMapper, httpHeader, providers); } private enum Scope { diff --git a/java-client/src/main/java/energy/trolie/client/spp/SppApiTokenHeaderProvider.java b/java-client/src/main/java/energy/trolie/client/spp/SppApiTokenHeaderProvider.java new file mode 100644 index 0000000..36ad9d7 --- /dev/null +++ b/java-client/src/main/java/energy/trolie/client/spp/SppApiTokenHeaderProvider.java @@ -0,0 +1,93 @@ +package energy.trolie.client.spp; + +import energy.trolie.client.RequestHeaderProvider; +import energy.trolie.client.TrolieRequestContext; +import lombok.extern.slf4j.Slf4j; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.time.Clock; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.Base64; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; + +/** + * A {@link RequestHeaderProvider} that implements the SPP Two-Factor Authentication + * (TFA) token generation as specified in the SPP TFA Technical Specifications v1.4. + *

+ * For every outgoing request, this provider generates a fresh {@code X-SPP-API-Token} + * header containing: + *

    + *
  • A UTC timestamp
  • + *
  • A unique nonce (UUID, max 128 characters)
  • + *
  • An HMAC-SHA512 signature over the request metadata
  • + *
+ */ + +@Slf4j +public final class SppApiTokenHeaderProvider implements RequestHeaderProvider { + + private static final String HEADER_NAME = "X-SPP-API-Token"; + + + private final String screenName; + private final byte[] decodedApiKey; + private final Clock clock; + + /** + * Creates an {@link SppApiTokenHeaderProvider} with the given credentials. + * + * @param screenName the SPP-assigned screen name for this client + * @param apiKey the Base64-encoded API key assigned by SPP + * @param clock the clock to use for timestamp generation (use {@link Clock#systemUTC()} in production) + */ + public SppApiTokenHeaderProvider(String screenName, String apiKey, Clock clock) { + this.screenName = screenName; + this.decodedApiKey = Base64.getDecoder().decode(apiKey); + this.clock = clock; + } + + @Override + public Map headersFor(TrolieRequestContext context) { + // 1. Generate timestamp (truncated to seconds, ISO-8601) + String timestamp = DateTimeFormatter.ISO_INSTANT + .format(clock.instant().truncatedTo(ChronoUnit.SECONDS)); + + // 2. Generate unique nonce (UUID satisfies the 128-char max requirement) + String nonce = UUID.randomUUID().toString(); + + // 3. Lowercase the screen name and request path (as per TFA spec) + String lowerScreenName = screenName.toLowerCase(Locale.ROOT); + String lowerPath = context.uri().getPath().toLowerCase(Locale.ROOT); + + // 4. Build the string to sign + String stringToSign = nonce + timestamp + lowerScreenName + lowerPath; + log.debug("Creating SPP API token with string to sign: {}", stringToSign); + + // 5. Compute HMAC-SHA512 and Base64-encode + byte[] hmacBytes; + try{ + Mac mac = Mac.getInstance("HmacSHA512"); + SecretKeySpec secretKeySpec = new SecretKeySpec(decodedApiKey, "HmacSHA512"); + mac.init(secretKeySpec); + hmacBytes = mac.doFinal(stringToSign.getBytes(StandardCharsets.UTF_8)); + } catch (NoSuchAlgorithmException | InvalidKeyException e) { + throw new RuntimeException(e); + } + var hmacHash = Base64.getEncoder().encodeToString(hmacBytes); + + log.debug("Generated HMAC hash: {}", hmacHash); + + // 6. Assemble the final token + String token = timestamp + "-" + nonce + "-" + hmacHash; + + return Map.of(HEADER_NAME, token); + } + +} \ No newline at end of file diff --git a/java-client/src/test/java/energy/trolie/client/TrolieClientIT.java b/java-client/src/test/java/energy/trolie/client/TrolieClientIT.java index 688252b..8dbefa2 100644 --- a/java-client/src/test/java/energy/trolie/client/TrolieClientIT.java +++ b/java-client/src/test/java/energy/trolie/client/TrolieClientIT.java @@ -33,6 +33,7 @@ import energy.trolie.client.request.operatingsnapshots.SeasonalSnapshotSubscribedReceiver; import energy.trolie.client.request.ratingproposals.ForecastRatingProposalUpdate; import energy.trolie.client.request.ratingproposals.RealTimeRatingProposalUpdate; +import energy.trolie.client.spp.SppApiTokenHeaderProvider; import lombok.extern.slf4j.Slf4j; import org.apache.hc.client5.http.HttpHostConnectException; import org.apache.hc.client5.http.classic.HttpClient; @@ -49,6 +50,7 @@ import org.apache.hc.core5.http.HttpRequestInterceptor; import org.apache.hc.core5.http.HttpResponseInterceptor; import org.apache.hc.core5.http.HttpStatus; +import org.apache.hc.core5.http.ProtocolException; import org.apache.hc.core5.http.impl.bootstrap.HttpServer; import org.apache.hc.core5.http.impl.io.DefaultBHttpServerConnectionFactory; import org.apache.hc.core5.http.impl.io.HttpService; @@ -62,13 +64,22 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; import javax.net.ServerSocketFactory; import java.io.IOException; import java.io.PipedInputStream; import java.io.PipedOutputStream; import java.net.InetAddress; import java.net.ServerSocket; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.time.Clock; import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Base64; import java.util.Collections; import java.util.List; import java.util.Map; @@ -80,6 +91,7 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Function; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -1781,6 +1793,112 @@ public void setSubscription(RequestSubscription subscription) { } } + @Test + void testDynamicRequestHeaderProvider() throws IOException { + String expectedHeaderName = "X-TROLIE-Auth"; + String expectedHeaderValue = "dynamic-token-123"; + + // Setup the request handler to verify the header exists + requestHandler = request -> { + try { + Assertions.assertNotNull(request.getHeader(expectedHeaderName), "Header should be present"); + } catch (ProtocolException e) { + throw new RuntimeException(e); + } + try { + Assertions.assertEquals(expectedHeaderValue, request.getHeader(expectedHeaderName).getValue()); + } catch (ProtocolException e) { + throw new RuntimeException(e); + } + + BasicClassicHttpResponse response = new BasicClassicHttpResponse(200); + + ForecastRatingProposalStatus status = ForecastRatingProposalStatus.builder().build(); + try { + String jsonResponse = objectMapper.writeValueAsString(status); + response.setEntity(new StringEntity(jsonResponse, ContentType.APPLICATION_JSON)); + } catch (Exception e) { + throw new RuntimeException(e); + } + + return response; + }; + + // Configure the client to use the new provider + HttpClientBuilder builder = HttpClientBuilder.create(); + RequestHeaderProvider headerProvider = (context) -> Map.of(expectedHeaderName, expectedHeaderValue); + + try (TrolieClient trolieClient = new TrolieClientBuilder(baseUri + "/test-path", builder.build()) + .addRequestHeaderProvider( headerProvider) + .build()) { + + //Perform a request that triggers the header provider + try (ForecastRatingProposalUpdate update = trolieClient.createForecastRatingProposalStreamingUpdate()) { + update.begin(ForecastProposalHeader.builder().begins(Instant.now()).build()); + update.complete(); + } + } + } + + @Test + void testSppApiTokenHeaderProvider_generatesCorrectHeader() throws NoSuchAlgorithmException, InvalidKeyException { + // Arrange + String screenName = "TestScreenName"; + String rawApiKey = "my-secret-api-key-987654321"; + String base64ApiKey = Base64.getEncoder().encodeToString(rawApiKey.getBytes(StandardCharsets.UTF_8)); + Instant fixedInstant = Instant.parse("2026-06-18T12:34:56Z"); + Clock clock = Clock.fixed(fixedInstant, ZoneOffset.UTC); + + SppApiTokenHeaderProvider provider = new SppApiTokenHeaderProvider(screenName, base64ApiKey, clock); + + TrolieRequestContext context = new TrolieRequestContext( + "GET", + URI.create("https://trolie.example.com/api/v1/ratings?query=abc"), + "application/json" + ); + + // Act + Map headers = provider.headersFor(context); + + // Assert + assertNotNull(headers); + assertTrue(headers.containsKey("X-SPP-API-Token")); + + String token = headers.get("X-SPP-API-Token"); + assertNotNull(token); + + // Token format should be: timestamp-nonce-hmacHash + // Timestamp is "2026-06-18T12:34:56Z" (length 20) + String expectedTimestamp = "2026-06-18T12:34:56Z"; + assertTrue(token.startsWith(expectedTimestamp + "-"), "Token should start with the expected timestamp followed by a hyphen"); + + // Extract materials following the prefix + String remainder = token.substring(expectedTimestamp.length() + 1); + + // A UUID has 36 characters (e.g., 8-4-4-4-12) + assertTrue(remainder.length() > 36, "Token remainder must be longer than UUID length"); + String nonceStr = remainder.substring(0, 36); + // Verify it is a valid UUID + assertDoesNotThrow(() -> UUID.fromString(nonceStr), "Nonce should be a valid UUID"); + + assertEquals('-', remainder.charAt(36), "Character after UUID should be a hyphen"); + String hmacHash = remainder.substring(37); + + // Verify HMAC authenticity + String lowerScreenName = "testscreenname"; + String lowerPath = "/api/v1/ratings"; + String expectedStringToSign = nonceStr + expectedTimestamp + lowerScreenName + lowerPath; + + byte[] expectedHmacBytes; + Mac mac = Mac.getInstance("HmacSHA512"); + SecretKeySpec secretKeySpec = new SecretKeySpec(rawApiKey.getBytes(StandardCharsets.UTF_8), "HmacSHA512"); + mac.init(secretKeySpec); + expectedHmacBytes = mac.doFinal(expectedStringToSign.getBytes(StandardCharsets.UTF_8)); + String expectedHmacHash = Base64.getEncoder().encodeToString(expectedHmacBytes); + + assertEquals(expectedHmacHash, hmacHash, "HMAC signature should match the expected signature over metadata"); + } + private void writeMonitoringSet(JsonGenerator json, String id) throws IOException { var source = DataProvenance.builder().provider(id).lastUpdated( Instant.now()).originId(id).build(); @@ -1895,4 +2013,292 @@ private void writeSeasonalSnapshot(JsonGenerator json, Instant startTime, String } + @Test + void testRequestHeaderProviderOnParameterizedGet() throws Exception { + // Verify that the RequestHeaderProvider is invoked on the GET path, + // that context.uri().getQuery() is non-null and contains the expected + // query parameters, and that context.contentType() is null for GETs. + + Instant startTime = Instant.now(); + String startTimeString = startTime.toString(); + + requestHandler = request -> { + BasicClassicHttpResponse response = new BasicClassicHttpResponse(200); + try { + PipedOutputStream out = new PipedOutputStream(); + PipedInputStream in = new PipedInputStream(out); + response.setEntity(new GzipCompressingEntity(new InputStreamEntity(in, + ContentType.create(TrolieApiConstants.CONTENT_TYPE_FORECAST_SNAPSHOT)))); + response.addHeader(HttpHeaders.CONTENT_ENCODING, "gzip"); + threadPoolExecutor.submit((Callable) () -> { + try (JsonGenerator json = new JsonFactory(objectMapper).createGenerator(out)) { + writeForecastSnapshot(json, startTime); + return null; + } + }); + } catch (Exception e) { + e.printStackTrace(); + response.setCode(HttpStatus.SC_INTERNAL_SERVER_ERROR); + } + return response; + }; + + AtomicInteger providerInvocationCount = new AtomicInteger(0); + // Captured context fields + String[] capturedQuery = {null}; + String[] capturedMethod = {null}; + String[] capturedContentType = {"NOT_SET"}; + + RequestHeaderProvider provider = context -> { + providerInvocationCount.incrementAndGet(); + capturedMethod[0] = context.method(); + capturedQuery[0] = context.uri().getQuery(); + capturedContentType[0] = context.contentType(); + return Map.of(); + }; + + HttpClientBuilder builder = HttpClientBuilder.create(); + try (TrolieClient trolieClient = new TrolieClientBuilder(baseUri, builder.build()) + .addRequestHeaderProvider(provider) + .build()) { + + trolieClient.getInUseLimitForecasts(new ForecastSnapshotReceiver() { + @Override public void header(ForecastSnapshotHeader header) {} + @Override public void beginSnapshot() {} + @Override public void endSnapshot() {} + @Override public void beginResource(String resourceId) {} + @Override public void period(ForecastPeriodSnapshot period) {} + @Override public void endResource() {} + }, "abc", null, startTime, startTime); + } + + // Provider must have been called exactly once for the single GET + Assertions.assertEquals(1, providerInvocationCount.get(), + "Provider should be invoked once for the GET request"); + + // Method must be GET + Assertions.assertEquals("GET", capturedMethod[0]); + + // Query must be non-null and contain the expected parameters + Assertions.assertNotNull(capturedQuery[0], + "context.uri().getQuery() must not be null for a parameterized GET"); + Assertions.assertTrue(capturedQuery[0].contains(TrolieApiConstants.PARAM_MONITORING_SET + "=abc"), + "Query should contain monitoring-set param"); + Assertions.assertTrue(capturedQuery[0].contains(TrolieApiConstants.PARAM_OFFSET_PERIOD_START + "=" + startTimeString), + "Query should contain offset-period-start param"); + Assertions.assertTrue(capturedQuery[0].contains(TrolieApiConstants.PARAM_PERIOD_END + "=" + startTimeString), + "Query should contain period-end param"); + + // contentType must be null for a GET (no request body) + Assertions.assertNull(capturedContentType[0], + "context.contentType() must be null for GET requests"); + } + + @Test + void testMultipleProvidersAndStaticHeaderPrecedence() throws IOException { + // Validates the documented merge order: + // static httpHeaders → provider1 → provider2 (later values overwrite earlier ones) + // + // Setup: + // X-Static set by static headers ("static-value") + // then overwritten by provider1 ("provider1-value") + // X-P1-Only set only by provider1 ("p1-only") + // then overwritten by provider2 ("provider2-wins") + // X-P2-Only set only by provider2 ("p2-only") + // + // Expected on the server: + // X-Static = "provider1-value" (provider1 overwrites static) + // X-P1-Only = "provider2-wins" (provider2 overwrites provider1) + // X-P2-Only = "p2-only" (only provider2 touches this key) + + requestHandler = request -> { + try { + Assertions.assertEquals("provider1-value", + request.getHeader("X-Static").getValue(), + "provider1 must overwrite the static header value"); + Assertions.assertEquals("provider2-wins", + request.getHeader("X-P1-Only").getValue(), + "provider2 must overwrite provider1 for the same key"); + Assertions.assertEquals("p2-only", + request.getHeader("X-P2-Only").getValue(), + "provider2-only header must be present"); + } catch (ProtocolException e) { + throw new RuntimeException(e); + } + + BasicClassicHttpResponse response = new BasicClassicHttpResponse(200); + try { + response.setEntity(new StringEntity( + objectMapper.writeValueAsString(ForecastRatingProposalStatus.builder().build()), + ContentType.APPLICATION_JSON)); + } catch (Exception e) { + throw new RuntimeException(e); + } + return response; + }; + + HttpClientBuilder builder = HttpClientBuilder.create(); + try (TrolieClient trolieClient = new TrolieClientBuilder(baseUri + "/test-path", builder.build()) + .httpHeaders(Map.of("X-Static", "static-value")) + .addRequestHeaderProvider(ctx -> Map.of( + "X-Static", "provider1-value", // overwrites static + "X-P1-Only", "p1-only")) + .addRequestHeaderProvider(ctx -> Map.of( + "X-P1-Only", "provider2-wins", // overwrites provider1 + "X-P2-Only", "p2-only")) + .build()) { + + try (ForecastRatingProposalUpdate update = trolieClient.createForecastRatingProposalStreamingUpdate()) { + update.begin(ForecastProposalHeader.builder().begins(Instant.now()).build()); + update.complete(); + } + } + } + + @Test + void testProviderReInvocationOnSubscribedGetPolls() throws Exception { + // Test that verifies providers are re-called on repeated poll cycles. + // This validates that for subscribed-GET requests, the RequestHeaderProvider + // is invoked fresh on each poll cycle, ensuring dynamic content (e.g., auth tokens, nonces) + // is regenerated for every request. + + AtomicInteger requestCounter = new AtomicInteger(0); + AtomicInteger providerInvocationCount = new AtomicInteger(0); + String etag = UUID.randomUUID().toString(); + Instant startTime = Instant.now(); + + requestHandler = request -> { + BasicClassicHttpResponse response = new BasicClassicHttpResponse(200); + + try { + // Verify that the request has the expected header from the provider + Header dynamicHeader = request.getHeader("X-Provider-Test"); + Assertions.assertNotNull(dynamicHeader, "Provider header should be present on every request"); + Assertions.assertEquals("provider-invocation-" + requestCounter.get(), dynamicHeader.getValue()); + + if (requestCounter.get() == 0) { + // First poll: return forecast snapshot with etag + PipedOutputStream out = new PipedOutputStream(); + PipedInputStream in = new PipedInputStream(out); + + response.setHeader(HttpHeaders.ETAG, etag); + response.setEntity( + new GzipCompressingEntity(new InputStreamEntity(in, ContentType.create(TrolieApiConstants.CONTENT_TYPE_FORECAST_SNAPSHOT)))); + response.addHeader(HttpHeaders.CONTENT_ENCODING, "gzip"); + threadPoolExecutor.submit(new Callable() { + @Override + public Void call() throws Exception { + try (JsonGenerator json = new JsonFactory(objectMapper).createGenerator(out)) { + writeForecastSnapshot(json, startTime); + return null; + } catch (Exception e) { + e.printStackTrace(); + throw new RuntimeException(e); + } + } + }); + } else if (requestCounter.get() == 1) { + // Second poll: return 304 Not Modified (provider should still be called) + response.setCode(HttpStatus.SC_NOT_MODIFIED); + } + + } catch (Exception e) { + e.printStackTrace(); + response.setCode(HttpStatus.SC_INTERNAL_SERVER_ERROR); + } finally { + requestCounter.incrementAndGet(); + } + + return response; + }; + + HttpClientBuilder builder = HttpClientBuilder.create(); + try (TrolieClient trolieClient = new TrolieClientBuilder(baseUri, builder.build()) + .forecastRatingsPollMs(200) + .addRequestHeaderProvider(context -> { + // Increment provider invocation counter + int invocationNumber = providerInvocationCount.getAndIncrement(); + log.info("Provider invoked for poll cycle {}", invocationNumber); + // Return headers that include the invocation number for verification + return Map.of("X-Provider-Test", "provider-invocation-" + requestCounter.get()); + }) + .build()) { + + AtomicInteger snapshotsReceived = new AtomicInteger(0); + AtomicInteger errorCount = new AtomicInteger(0); + + // Subscribe and run for two poll cycles + var subscription = trolieClient.subscribeToInUseLimitForecastUpdates(new ForecastSnapshotSubscribedReceiver() { + + RequestSubscription subscription; + int numResources; + int numPeriods; + + @Override + public void period(ForecastPeriodSnapshot period) { + numPeriods++; + } + + @Override + public void header(ForecastSnapshotHeader header) { + Assertions.assertEquals(startTime, header.getBegins()); + } + + @Override + public void endSnapshot() { + Assertions.assertEquals(100, numResources); + numResources = 0; + } + + @Override + public void endResource() { + Assertions.assertEquals(24, numPeriods); + numPeriods = 0; + } + + @Override + public void beginSnapshot() { + snapshotsReceived.incrementAndGet(); + } + + @Override + public void beginResource(String resourceId) { + numResources++; + } + + @Override + public void error(StreamingGetException t) { + errorCount.incrementAndGet(); + ((RequestSubscriptionInternal)subscription).stop(); + } + + @Override + public void setSubscription(RequestSubscription subscription) { + this.subscription = subscription; + } + + }, "abc"); + + // Wait for two poll cycles to complete + // First request sends data, second request gets 304 Not Modified + int waitCycles = 0; + while (subscription.isSubscribed() && waitCycles < 20) { + Thread.sleep(100); + waitCycles++; + if (requestCounter.get() >= 2) { + ((RequestSubscriptionInternal) subscription).stop(); + break; + } + } + + // Verify the provider was invoked on each poll cycle + Assertions.assertEquals(2, providerInvocationCount.get(), + "Provider should be invoked exactly twice (once per poll cycle)"); + Assertions.assertEquals(1, snapshotsReceived.get(), + "Should have received one snapshot on first poll"); + Assertions.assertEquals(0, errorCount.get(), + "Should have no errors during subscription"); + } + } + }