From 6f50ee2bb673e576fe80de2ed5bd291d6545c964 Mon Sep 17 00:00:00 2001 From: Kator James Date: Fri, 18 Jul 2025 04:12:39 +0100 Subject: [PATCH 1/4] Enhance OpaClient and OpaConfiguration to support custom headers - Added header management in OpaClient.Builder to allow users to specify headers for OPA requests. - Updated OpaConfiguration to accept headers in its constructor and provide a method to retrieve them. - Modified OpaRestClient to include headers in HTTP requests. - Improved code formatting and documentation for clarity. --- .../com/bisnode/opa/client/OpaClient.java | 62 +++++++++++++++++-- .../bisnode/opa/client/OpaConfiguration.java | 62 +++++++++++++++---- .../opa/client/rest/OpaRestClient.java | 24 ++++--- 3 files changed, 125 insertions(+), 23 deletions(-) diff --git a/src/main/java/com/bisnode/opa/client/OpaClient.java b/src/main/java/com/bisnode/opa/client/OpaClient.java index ffbe7a7..618b44f 100644 --- a/src/main/java/com/bisnode/opa/client/OpaClient.java +++ b/src/main/java/com/bisnode/opa/client/OpaClient.java @@ -1,6 +1,5 @@ package com.bisnode.opa.client; - import com.bisnode.opa.client.data.OpaDataApi; import com.bisnode.opa.client.data.OpaDataClient; import com.bisnode.opa.client.data.OpaDocument; @@ -16,11 +15,14 @@ import java.lang.reflect.ParameterizedType; import java.net.http.HttpClient; +import java.util.HashMap; +import java.util.Map; import java.util.Objects; import java.util.Optional; /** - * Opa client featuring {@link OpaDataApi}, {@link OpaQueryApi} and {@link OpaPolicyApi} + * Opa client featuring {@link OpaDataApi}, {@link OpaQueryApi} and + * {@link OpaPolicyApi} */ public class OpaClient implements OpaQueryApi, OpaDataApi, OpaPolicyApi { @@ -75,20 +77,71 @@ public void createOrUpdatePolicy(OpaPolicy policy) { public static class Builder { private OpaConfiguration opaConfiguration; private ObjectMapper objectMapper; + private Map headers = new HashMap<>(); /** * @param url URL including protocol and port */ public Builder opaConfiguration(String url) { - this.opaConfiguration = new OpaConfiguration(url); + this.opaConfiguration = new OpaConfiguration(url, HttpClient.Version.HTTP_1_1, headers); return this; } + /** + * @param objectMapper ObjectMapper to be used for JSON + * serialization/deserialization + */ public Builder objectMapper(ObjectMapper objectMapper) { this.objectMapper = objectMapper; return this; } + /** + * Add a header that will be included in all requests to the OPA server + * + * @param name header name + * @param value header value + * @return this builder instance + */ + public Builder header(String name, String value) { + Objects.requireNonNull(name, "Header name cannot be null"); + Objects.requireNonNull(value, "Header value cannot be null"); + this.headers.put(name, value); + // Recreate configuration if it already exists to include the new header + if (this.opaConfiguration != null) { + this.opaConfiguration = new OpaConfiguration( + this.opaConfiguration.getUrl(), + this.opaConfiguration.getHttpVersion(), + this.headers); + } + return this; + } + + /** + * Add multiple headers that will be included in all requests to the OPA + * server + * + * @param headers map of header names to values + * @return this builder instance + */ + public Builder headers(Map headers) { + if (headers != null) { + headers.forEach((name, value) -> { + Objects.requireNonNull(name, "Header name cannot be null"); + Objects.requireNonNull(value, "Header value cannot be null"); + }); + this.headers.putAll(headers); + // Recreate configuration if it already exists to include the new headers + if (this.opaConfiguration != null) { + this.opaConfiguration = new OpaConfiguration( + this.opaConfiguration.getUrl(), + this.opaConfiguration.getHttpVersion(), + this.headers); + } + } + return this; + } + public OpaClient build() { Objects.requireNonNull(opaConfiguration, "build() called without opaConfiguration provided"); HttpClient httpClient = HttpClient.newBuilder() @@ -97,7 +150,8 @@ public OpaClient build() { ObjectMapper objectMapper = Optional.ofNullable(this.objectMapper) .orElseGet(ObjectMapperFactory.getInstance()::create); OpaRestClient opaRestClient = new OpaRestClient(opaConfiguration, httpClient, objectMapper); - return new OpaClient(new OpaQueryClient(opaRestClient), new OpaDataClient(opaRestClient), new OpaPolicyClient(opaRestClient)); + return new OpaClient(new OpaQueryClient(opaRestClient), new OpaDataClient(opaRestClient), + new OpaPolicyClient(opaRestClient)); } } } diff --git a/src/main/java/com/bisnode/opa/client/OpaConfiguration.java b/src/main/java/com/bisnode/opa/client/OpaConfiguration.java index 2fa2841..6e98387 100644 --- a/src/main/java/com/bisnode/opa/client/OpaConfiguration.java +++ b/src/main/java/com/bisnode/opa/client/OpaConfiguration.java @@ -3,6 +3,9 @@ import java.beans.ConstructorProperties; import java.net.URI; import java.net.http.HttpClient; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; import java.util.Objects; /** @@ -11,26 +14,45 @@ public final class OpaConfiguration { private final String url; private final HttpClient.Version httpVersion; + private final Map headersMap; /** - * @param url base URL to OPA server, containing protocol, and port (eg. http://localhost:8181) + * @param url base URL to OPA server, containing protocol, and port (eg. + * http://localhost:8181) */ - @ConstructorProperties({"url"}) + @ConstructorProperties({ "url" }) public OpaConfiguration(String url) { this.url = url; - this.httpVersion = "https".equals(URI.create(url).getScheme()) ? - HttpClient.Version.HTTP_2 : - HttpClient.Version.HTTP_1_1; + this.httpVersion = "https".equals(URI.create(url).getScheme()) ? HttpClient.Version.HTTP_2 + : HttpClient.Version.HTTP_1_1; + this.headersMap = Collections.emptyMap(); } /** - * @param url base URL to OPA server, containing protocol, and port (eg. http://localhost:8181) + * @param url base URL to OPA server, containing protocol, and port (eg. + * http://localhost:8181) * @param httpVersion preferred HTTP version to use for the client */ - @ConstructorProperties({"url", "httpVersion"}) + @ConstructorProperties({ "url", "httpVersion" }) public OpaConfiguration(String url, HttpClient.Version httpVersion) { this.url = url; this.httpVersion = httpVersion; + this.headersMap = Collections.emptyMap(); + } + + /** + * @param url base URL to OPA server, containing protocol, and port + * (eg. + * http://localhost:8181) + * @param httpVersion preferred HTTP version to use for the client + * @param headers headers to be added to all requests + */ + @ConstructorProperties({ "url", "httpVersion", "headers" }) + public OpaConfiguration(String url, HttpClient.Version httpVersion, Map headers) { + this.url = url; + this.httpVersion = httpVersion; + this.headersMap = headers != null ? Collections.unmodifiableMap(new HashMap<>(headers)) + : Collections.emptyMap(); } /** @@ -41,7 +63,8 @@ public String getUrl() { } /** - * Get HTTP version configured for the client. If not configured will use HTTP2 for "https" scheme + * Get HTTP version configured for the client. If not configured will use HTTP2 + * for "https" scheme * and HTTP1.1 for "http" scheme. * * @return httpVersion configured for use by the client @@ -50,23 +73,38 @@ public HttpClient.Version getHttpVersion() { return this.httpVersion; } + /** + * Get headers that will be added to all requests + * + * @return unmodifiable map of headers + */ + public Map getHeaders() { + return this.headersMap; + } + @Override public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; OpaConfiguration that = (OpaConfiguration) o; - return Objects.equals(url, that.url); + return Objects.equals(url, that.url) && + Objects.equals(httpVersion, that.httpVersion) && + Objects.equals(headersMap, that.headersMap); } @Override public int hashCode() { - return Objects.hash(url); + return Objects.hash(url, httpVersion, headersMap); } @Override public String toString() { return "OpaConfiguration{" + "url='" + url + '\'' + + ", httpVersion=" + httpVersion + + ", headers=" + headersMap + '}'; } } diff --git a/src/main/java/com/bisnode/opa/client/rest/OpaRestClient.java b/src/main/java/com/bisnode/opa/client/rest/OpaRestClient.java index f1cf5e1..54651d7 100644 --- a/src/main/java/com/bisnode/opa/client/rest/OpaRestClient.java +++ b/src/main/java/com/bisnode/opa/client/rest/OpaRestClient.java @@ -29,18 +29,25 @@ public OpaRestClient(OpaConfiguration opaConfiguration, HttpClient httpClient, O } /** - * Create {@link java.net.http.HttpRequest.Builder} with configured url using provided endpoint + * Create {@link java.net.http.HttpRequest.Builder} with configured url using + * provided endpoint * * @param endpoint desired opa endpoint * @throws OpaClientException if URL or endpoint is invalid */ public HttpRequest.Builder getBasicRequestBuilder(String endpoint) { OpaUrl url = OpaUrl.of(opaConfiguration.getUrl(), endpoint).normalized(); - return HttpRequest.newBuilder(url.toUri()); + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(url.toUri()); + + // Add headers from configuration + opaConfiguration.getHeaders().forEach(requestBuilder::header); + + return requestBuilder; } /** - * Gets {@link java.net.http.HttpRequest.BodyPublisher} that is capable of serializing to JSON + * Gets {@link java.net.http.HttpRequest.BodyPublisher} that is capable of + * serializing to JSON * * @param body object to be serialized */ @@ -51,7 +58,7 @@ public HttpRequest.BodyPublisher getJsonBodyPublisher(Object body) throws JsonPr /** * Gets {@link JsonBodyHandler} that will deserialize JSON to desired class type * - * @param responseType desired response type + * @param responseType desired response type * @param desired response type */ public JsonBodyHandler getJsonBodyHandler(JavaType responseType) { @@ -59,7 +66,8 @@ public JsonBodyHandler getJsonBodyHandler(JavaType responseType) { } /** - * Sends provided request and returns response mapped using {@link java.net.http.HttpResponse.BodyHandler} + * Sends provided request and returns response mapped using + * {@link java.net.http.HttpResponse.BodyHandler} * * @param request request to be sent * @param bodyHandler handler that indicates how to transform incoming body @@ -68,11 +76,13 @@ public JsonBodyHandler getJsonBodyHandler(JavaType responseType) { * @throws IOException is propagated from {@link HttpClient} * @throws InterruptedException is propagated from {@link HttpClient} */ - public HttpResponse sendRequest(HttpRequest request, HttpResponse.BodyHandler bodyHandler) throws IOException, InterruptedException { + public HttpResponse sendRequest(HttpRequest request, HttpResponse.BodyHandler bodyHandler) + throws IOException, InterruptedException { try { HttpResponse response = httpClient.send(request, bodyHandler); if (response.statusCode() >= 300) { - throw new OpaClientException("Error in communication with OPA server, status code: " + response.statusCode()); + throw new OpaClientException( + "Error in communication with OPA server, status code: " + response.statusCode()); } return response; } catch (SocketException exception) { From 72a9c6c6be1fac6b9085cacb0080c637f2ec6fef Mon Sep 17 00:00:00 2001 From: Kator James Date: Fri, 18 Jul 2025 04:13:23 +0100 Subject: [PATCH 2/4] Refactor XmasHappening example to include custom headers in OPA requests - Updated the callOpa method to add custom headers for API requests, enhancing the OpaClient's functionality. - Improved code formatting for better readability. --- .../src/main/java/com/bisnode/opa/XmasHappening.java | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/examples/query-for-document/src/main/java/com/bisnode/opa/XmasHappening.java b/examples/query-for-document/src/main/java/com/bisnode/opa/XmasHappening.java index dcaa963..6fcc26b 100644 --- a/examples/query-for-document/src/main/java/com/bisnode/opa/XmasHappening.java +++ b/examples/query-for-document/src/main/java/com/bisnode/opa/XmasHappening.java @@ -16,14 +16,12 @@ public class XmasHappening { public static final String NAME_QUERY_PATH = "name_rule/access_to_chimney"; public static final String OPA_URL = "http://localhost:8181/"; - public static void main(String[] args) { - List sleighCrew = List.of( new OpaInput("SantaClaus", 99, SantaPartyMember.SANTA.name()), new OpaInput("Buddy", 17, SantaPartyMember.ELF.name()), - new OpaInput("Grinch",22, SantaPartyMember.GRINCH.name()), + new OpaInput("Grinch", 22, SantaPartyMember.GRINCH.name()), new OpaInput("Rudolf", 33, SantaPartyMember.REINDEER.name()), new OpaInput("Niko", 25, SantaPartyMember.REINDEER.name())); @@ -31,9 +29,13 @@ public static void main(String[] args) { } - static void callOpa(OpaInput testInput){ + static void callOpa(OpaInput testInput) { log.info("Creating query for input {}", testInput); - OpaQueryApi queryApi = OpaClient.builder().opaConfiguration(OPA_URL).build(); + OpaQueryApi queryApi = OpaClient.builder() + .opaConfiguration(OPA_URL) + .header("X-API-Key", "secret-key-12345") + .header("X-Request-Source", "xmas-app") + .build(); QueryForDocumentRequest ageRequest = new QueryForDocumentRequest(testInput, AGE_QUERY_PATH); OpaOutput ageResponse = queryApi.queryForDocument(ageRequest, OpaOutput.class); From 7a74013c10dc16b9a68db9ad0f98e9e9558e03e0 Mon Sep 17 00:00:00 2001 From: Kator James Date: Fri, 18 Jul 2025 04:14:22 +0100 Subject: [PATCH 3/4] Add tests to verify header inclusion in OPA requests --- .../opa/client/ManagingDocumentSpec.groovy | 26 +++++ .../opa/client/ManagingPolicySpec.groovy | 26 +++++ .../opa/client/OpaClientBuilderSpec.groovy | 95 +++++++++++++++++++ .../opa/client/QueryingForDocumentSpec.groovy | 27 ++++++ 4 files changed, 174 insertions(+) diff --git a/src/test/groovy/com/bisnode/opa/client/ManagingDocumentSpec.groovy b/src/test/groovy/com/bisnode/opa/client/ManagingDocumentSpec.groovy index 288aa00..4589142 100644 --- a/src/test/groovy/com/bisnode/opa/client/ManagingDocumentSpec.groovy +++ b/src/test/groovy/com/bisnode/opa/client/ManagingDocumentSpec.groovy @@ -93,4 +93,30 @@ class ManagingDocumentSpec extends Specification { thrown(OpaServerConnectionException) } + + def 'should include headers in document requests'() { + given: + def documentPath = 'somePath' + def endpoint = "/v1/data/$documentPath" + def headerName = 'X-API-Key' + def headerValue = 'secret-key-123' + def client = OpaClient.builder() + .opaConfiguration(url) + .header(headerName, headerValue) + .build() + wireMockServer + .stubFor(put(urlEqualTo(endpoint)) + .withHeader(ContentType.HEADER_NAME, equalTo(APPLICATION_JSON)) + .withHeader(headerName, equalTo(headerValue)) + .willReturn(aResponse() + .withStatus(200) + .withHeader(ContentType.HEADER_NAME, APPLICATION_JSON) + .withBody('{}'))) + when: + client.createOrOverwriteDocument(new OpaDocument(documentPath, DOCUMENT)) + then: + noExceptionThrown() + wireMockServer.verify(putRequestedFor(urlEqualTo(endpoint)) + .withHeader(headerName, equalTo(headerValue))) + } } diff --git a/src/test/groovy/com/bisnode/opa/client/ManagingPolicySpec.groovy b/src/test/groovy/com/bisnode/opa/client/ManagingPolicySpec.groovy index 6eff973..4e1ec4a 100644 --- a/src/test/groovy/com/bisnode/opa/client/ManagingPolicySpec.groovy +++ b/src/test/groovy/com/bisnode/opa/client/ManagingPolicySpec.groovy @@ -98,4 +98,30 @@ class ManagingPolicySpec extends Specification { thrown(OpaServerConnectionException) } + + def 'should include headers in policy requests'() { + given: + def policyId = '12345' + def endpoint = "/v1/policies/$policyId" + def headerName = 'X-API-Key' + def headerValue = 'secret-key-123' + def client = OpaClient.builder() + .opaConfiguration(url) + .header(headerName, headerValue) + .build() + wireMockServer + .stubFor(put(urlEqualTo(endpoint)) + .withHeader(ContentType.HEADER_NAME, equalTo(TEXT_PLAIN)) + .withHeader(headerName, equalTo(headerValue)) + .willReturn(aResponse() + .withStatus(200) + .withHeader(ContentType.HEADER_NAME, APPLICATION_JSON) + .withBody('{}'))) + when: + client.createOrUpdatePolicy(new OpaPolicy(policyId, POLICY)) + then: + noExceptionThrown() + wireMockServer.verify(putRequestedFor(urlEqualTo(endpoint)) + .withHeader(headerName, equalTo(headerValue))) + } } diff --git a/src/test/groovy/com/bisnode/opa/client/OpaClientBuilderSpec.groovy b/src/test/groovy/com/bisnode/opa/client/OpaClientBuilderSpec.groovy index e0024cc..c7756a0 100644 --- a/src/test/groovy/com/bisnode/opa/client/OpaClientBuilderSpec.groovy +++ b/src/test/groovy/com/bisnode/opa/client/OpaClientBuilderSpec.groovy @@ -75,4 +75,99 @@ class OpaClientBuilderSpec extends Specification { result != null result.get("authorized") == true } + + def 'should include single header in requests'() { + given: + def path = 'someDocument' + def endpoint = "/v1/data/$path" + def headerName = 'X-API-Key' + def headerValue = 'secret-api-key' + wireMockServer + .stubFor(post(urlEqualTo(endpoint)) + .withHeader(ContentType.HEADER_NAME, equalTo(APPLICATION_JSON)) + .withHeader(headerName, equalTo(headerValue)) + .willReturn(aResponse() + .withStatus(200) + .withHeader(ContentType.HEADER_NAME, APPLICATION_JSON) + .withBody('{"result": {"authorized": true}}'))) + def opaClient = OpaClient.builder() + .opaConfiguration(url) + .header(headerName, headerValue) + .build(); + + when: + opaClient.queryForDocument(new QueryForDocumentRequest([shouldPass: true], path), Object.class) + + then: + wireMockServer.verify(postRequestedFor(urlEqualTo(endpoint)) + .withHeader(headerName, equalTo(headerValue))) + } + + def 'should include multiple headers in requests'() { + given: + def path = 'someDocument' + def endpoint = "/v1/data/$path" + def headers = [ + 'X-API-Key': 'secret-api-key', + 'X-Tenant-ID': 'tenant-123', + 'Authorization': 'Bearer token123' + ] + wireMockServer + .stubFor(post(urlEqualTo(endpoint)) + .withHeader(ContentType.HEADER_NAME, equalTo(APPLICATION_JSON)) + .withHeader('X-API-Key', equalTo('secret-api-key')) + .withHeader('X-Tenant-ID', equalTo('tenant-123')) + .withHeader('Authorization', equalTo('Bearer token123')) + .willReturn(aResponse() + .withStatus(200) + .withHeader(ContentType.HEADER_NAME, APPLICATION_JSON) + .withBody('{"result": {"authorized": true}}'))) + def opaClient = OpaClient.builder() + .opaConfiguration(url) + .headers(headers) + .build(); + + when: + opaClient.queryForDocument(new QueryForDocumentRequest([shouldPass: true], path), Object.class) + + then: + wireMockServer.verify(postRequestedFor(urlEqualTo(endpoint)) + .withHeader('X-API-Key', equalTo('secret-api-key')) + .withHeader('X-Tenant-ID', equalTo('tenant-123')) + .withHeader('Authorization', equalTo('Bearer token123'))) + } + + def 'should combine individual header with multiple headers'() { + given: + def path = 'someDocument' + def endpoint = "/v1/data/$path" + def headers = [ + 'X-API-Key': 'secret-api-key', + 'X-Tenant-ID': 'tenant-123' + ] + wireMockServer + .stubFor(post(urlEqualTo(endpoint)) + .withHeader(ContentType.HEADER_NAME, equalTo(APPLICATION_JSON)) + .withHeader('X-API-Key', equalTo('secret-api-key')) + .withHeader('X-Tenant-ID', equalTo('tenant-123')) + .withHeader('Authorization', equalTo('Bearer token123')) + .willReturn(aResponse() + .withStatus(200) + .withHeader(ContentType.HEADER_NAME, APPLICATION_JSON) + .withBody('{"result": {"authorized": true}}'))) + def opaClient = OpaClient.builder() + .opaConfiguration(url) + .headers(headers) + .header('Authorization', 'Bearer token123') + .build(); + + when: + opaClient.queryForDocument(new QueryForDocumentRequest([shouldPass: true], path), Object.class) + + then: + wireMockServer.verify(postRequestedFor(urlEqualTo(endpoint)) + .withHeader('X-API-Key', equalTo('secret-api-key')) + .withHeader('X-Tenant-ID', equalTo('tenant-123')) + .withHeader('Authorization', equalTo('Bearer token123'))) + } } diff --git a/src/test/groovy/com/bisnode/opa/client/QueryingForDocumentSpec.groovy b/src/test/groovy/com/bisnode/opa/client/QueryingForDocumentSpec.groovy index 2d35cf0..796976f 100644 --- a/src/test/groovy/com/bisnode/opa/client/QueryingForDocumentSpec.groovy +++ b/src/test/groovy/com/bisnode/opa/client/QueryingForDocumentSpec.groovy @@ -213,6 +213,33 @@ class QueryingForDocumentSpec extends Specification { } + def 'should include headers in query requests'() { + given: + def path = 'someDocument' + def endpoint = "/v1/data/$path" + def headerName = 'X-API-Key' + def headerValue = 'secret-key-123' + def client = OpaClient.builder() + .opaConfiguration(url) + .header(headerName, headerValue) + .build() + wireMockServer + .stubFor(post(urlEqualTo(endpoint)) + .withHeader(ContentType.HEADER_NAME, equalTo(APPLICATION_JSON)) + .withHeader(headerName, equalTo(headerValue)) + .willReturn(aResponse() + .withStatus(200) + .withHeader(ContentType.HEADER_NAME, APPLICATION_JSON) + .withBody('{"result": {"allow":"true"}}'))) + when: + def result = client.queryForDocument(new QueryForDocumentRequest([shouldPass: true], path), ValidationResult.class) + then: + noExceptionThrown() + wireMockServer.verify(postRequestedFor(urlEqualTo(endpoint)) + .withHeader(headerName, equalTo(headerValue))) + result.allow + } + static final class ValidationResult { Boolean allow } From 31934c44c7506c916e084b949c227932c671017c Mon Sep 17 00:00:00 2001 From: Kator James Date: Fri, 18 Jul 2025 04:16:51 +0100 Subject: [PATCH 4/4] Update README.md to include setting headers and usage examples --- README.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/README.md b/README.md index 9fdb159..7a97ed5 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,37 @@ DesiredResponse response = client.queryForDocument(new QueryForDocumentRequest(y // Do whatever you like with the response ``` +### Setting Headers + +You can add headers that will be included in all requests to the OPA server: + +```java +// Add a single header +OpaClient client = OpaClient.builder() + .opaConfiguration("http://localhost:8181") + .header("X-API-Key", "your-api-key") + .header("Authorization", "Bearer your-token") + .build(); + +// Add multiple headers at once +Map headers = new HashMap<>(); +headers.put("X-API-Key", "your-api-key"); +headers.put("X-Tenant-ID", "tenant-123"); +headers.put("Authorization", "Bearer your-token"); + +OpaClient client = OpaClient.builder() + .opaConfiguration("http://localhost:8181") + .headers(headers) + .build(); + +// You can also combine both approaches +OpaClient client = OpaClient.builder() + .opaConfiguration("http://localhost:8181") + .headers(headers) + .header("X-Request-ID", "req-456") + .build(); +``` + ### Query for a list of documents This requires [commons-lang3](https://mvnrepository.com/artifact/org.apache.commons/commons-lang3) to be present on your classpath.