From 07a59a7821f0a7dfaae0554e111068323751d2f9 Mon Sep 17 00:00:00 2001 From: Ewan Harris Date: Tue, 7 Oct 2025 11:36:24 +0100 Subject: [PATCH 1/3] fix(java-sdk): ensure headers can be set and overriden --- .../template/README_initializing.mustache | 63 ++ .../api/client/OpenFgaClient.java.mustache | 59 +- ...lientBatchCheckClientOptions.java.mustache | 3 +- .../ClientConfiguration.java.mustache | 6 + .../ClientListRelationsOptions.java.mustache | 2 + .../OpenFgaClientHeadersTest.java.mustache | 963 ++++++++++++++++++ .../client/OpenFgaClientTest.java.mustache | 12 +- config/common/files/README.mustache | 1 + 8 files changed, 1077 insertions(+), 32 deletions(-) create mode 100644 config/clients/java/template/src/test/api/client/OpenFgaClientHeadersTest.java.mustache diff --git a/config/clients/java/template/README_initializing.mustache b/config/clients/java/template/README_initializing.mustache index 07e7c5a9..5cdbcf0e 100644 --- a/config/clients/java/template/README_initializing.mustache +++ b/config/clients/java/template/README_initializing.mustache @@ -108,3 +108,66 @@ public class Example { } } ``` + +### Custom Headers + +#### Default Headers + +You can set default headers to be sent with every request by using the `defaultHeaders` property of the `ClientConfiguration` class. + +```java +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.openfga.sdk.api.client.OpenFgaClient; +import dev.openfga.sdk.api.configuration.ClientConfiguration; + +import java.net.http.HttpClient; +import java.util.Map; + +public class Example { + public static void main(String[] args) throws Exception { + var config = new ClientConfiguration() + .apiUrl(System.getenv("FGA_API_URL")) + .storeId(System.getenv("FGA_STORE_ID")) + .authorizationModelId(System.getenv("FGA_MODEL_ID")) + .defaultHeaders(Map.of( + "X-Custom-Header", "default-value", + "X-Request-Source", "my-app" + )); + + var fgaClient = new OpenFgaClient(config); + } +} +``` + +#### Per-request Headers + +You can set custom headers to be sent with a specific request by using the `additionalHeaders` property of the options classes (e.g. `ClientReadOptions`, `ClientWriteOptions`, etc.). + +```java +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.openfga.sdk.api.client.OpenFgaClient; +import dev.openfga.sdk.api.configuration.ClientConfiguration; +import java.net.http.HttpClient; + +public class Example { + public static void main(String[] args) throws Exception { + var config = new ClientConfiguration() + .apiUrl(System.getenv("FGA_API_URL")) + .storeId(System.getenv("FGA_STORE_ID")) + .authorizationModelId(System.getenv("FGA_MODEL_ID")) + .defaultHeaders(Map.of( + "X-Custom-Header", "default-value", + "X-Request-Source", "my-app" + )); + + var fgaClient = new OpenFgaClient(config); + var options = new ClientReadOptions() + .additionalHeaders(Map.of( + "X-Request-Id", "123e4567-e89b-12d3-a456-426614174000", + "X-Custom-Header", "overridden-value" // this will override the default value for this request only + ) + ); + var response = fgaClient.read(request, options).get(); + } +} +``` diff --git a/config/clients/java/template/src/main/api/client/OpenFgaClient.java.mustache b/config/clients/java/template/src/main/api/client/OpenFgaClient.java.mustache index 7599d6c1..890c2869 100644 --- a/config/clients/java/template/src/main/api/client/OpenFgaClient.java.mustache +++ b/config/clients/java/template/src/main/api/client/OpenFgaClient.java.mustache @@ -238,7 +238,13 @@ public class OpenFgaClient { ClientReadAuthorizationModelOptions options) throws FgaInvalidParameterException { configuration.assertValid(); String storeId = configuration.getStoreIdChecked(); - String authorizationModelId = options.getAuthorizationModelIdChecked(); + // Set authorizationModelId from options if available; otherwise, require a valid configuration value + String authorizationModelId; + if (options != null && !isNullOrWhitespace(options.getAuthorizationModelId())) { + authorizationModelId = options.getAuthorizationModelIdChecked(); + } else { + authorizationModelId = configuration.getAuthorizationModelIdChecked(); + } var overrides = new ConfigurationOverride().addHeaders(options); return call(() -> api.readAuthorizationModel(storeId, authorizationModelId, overrides)) .thenApply(ClientReadAuthorizationModelResponse::new); @@ -532,12 +538,12 @@ public class OpenFgaClient { ? writeOptions : new ClientWriteOptions().transactionChunkSize(DEFAULT_MAX_METHOD_PARALLEL_REQS); - if (options.getAdditionalHeaders() == null) { - options.additionalHeaders(new HashMap<>()); - } - options.getAdditionalHeaders().putIfAbsent(CLIENT_METHOD_HEADER, "Write"); - options.getAdditionalHeaders() - .putIfAbsent(CLIENT_BULK_REQUEST_ID_HEADER, randomUUID().toString()); + HashMap headers = options.getAdditionalHeaders() != null + ? new HashMap<>(options.getAdditionalHeaders()) + : new HashMap<>(); + headers.putIfAbsent(CLIENT_METHOD_HEADER, "Write"); + headers.putIfAbsent(CLIENT_BULK_REQUEST_ID_HEADER, randomUUID().toString()); + options.additionalHeaders(headers); int chunkSize = options.getTransactionChunkSize(); @@ -798,12 +804,13 @@ public class OpenFgaClient { var options = batchCheckOptions != null ? batchCheckOptions : new ClientBatchCheckClientOptions().maxParallelRequests(DEFAULT_MAX_METHOD_PARALLEL_REQS); - if (options.getAdditionalHeaders() == null) { - options.additionalHeaders(new HashMap<>()); - } - options.getAdditionalHeaders().putIfAbsent(CLIENT_METHOD_HEADER, "ClientBatchCheck"); - options.getAdditionalHeaders() - .putIfAbsent(CLIENT_BULK_REQUEST_ID_HEADER, randomUUID().toString()); + + HashMap headers = options.getAdditionalHeaders() != null + ? new HashMap<>(options.getAdditionalHeaders()) + : new HashMap<>(); + headers.putIfAbsent(CLIENT_METHOD_HEADER, "ClientBatchCheck"); + headers.putIfAbsent(CLIENT_BULK_REQUEST_ID_HEADER, randomUUID().toString()); + options.additionalHeaders(headers); int maxParallelRequests = options.getMaxParallelRequests() != null ? options.getMaxParallelRequests() @@ -858,12 +865,13 @@ public class OpenFgaClient { : new ClientBatchCheckOptions() .maxParallelRequests(DEFAULT_MAX_METHOD_PARALLEL_REQS) .maxBatchSize(DEFAULT_MAX_BATCH_SIZE); - if (options.getAdditionalHeaders() == null) { - options.additionalHeaders(new HashMap<>()); - } - options.getAdditionalHeaders().putIfAbsent(CLIENT_METHOD_HEADER, "BatchCheck"); - options.getAdditionalHeaders() - .putIfAbsent(CLIENT_BULK_REQUEST_ID_HEADER, randomUUID().toString()); + + HashMap headers = options.getAdditionalHeaders() != null + ? new HashMap<>(options.getAdditionalHeaders()) + : new HashMap<>(); + headers.putIfAbsent(CLIENT_METHOD_HEADER, "BatchCheck"); + headers.putIfAbsent(CLIENT_BULK_REQUEST_ID_HEADER, randomUUID().toString()); + options.additionalHeaders(headers); Map correlationIdToCheck = new HashMap<>(); @@ -1089,12 +1097,13 @@ public class OpenFgaClient { var options = listRelationsOptions != null ? listRelationsOptions : new ClientListRelationsOptions().maxParallelRequests(DEFAULT_MAX_METHOD_PARALLEL_REQS); - if (options.getAdditionalHeaders() == null) { - options.additionalHeaders(new HashMap<>()); - } - options.getAdditionalHeaders().putIfAbsent(CLIENT_METHOD_HEADER, "ListRelations"); - options.getAdditionalHeaders() - .putIfAbsent(CLIENT_BULK_REQUEST_ID_HEADER, randomUUID().toString()); + + HashMap headers = options.getAdditionalHeaders() != null + ? new HashMap<>(options.getAdditionalHeaders()) + : new HashMap<>(); + headers.putIfAbsent(CLIENT_METHOD_HEADER, "ListRelations"); + headers.putIfAbsent(CLIENT_BULK_REQUEST_ID_HEADER, randomUUID().toString()); + options.additionalHeaders(headers); var batchCheckRequests = request.getRelations().stream() .map(relation -> new ClientCheckRequest() diff --git a/config/clients/java/template/src/main/api/configuration/ClientBatchCheckClientOptions.java.mustache b/config/clients/java/template/src/main/api/configuration/ClientBatchCheckClientOptions.java.mustache index 340d5305..81bb9227 100644 --- a/config/clients/java/template/src/main/api/configuration/ClientBatchCheckClientOptions.java.mustache +++ b/config/clients/java/template/src/main/api/configuration/ClientBatchCheckClientOptions.java.mustache @@ -2,6 +2,7 @@ package {{configPackage}}; import dev.openfga.sdk.api.model.ConsistencyPreference; +import java.util.HashMap; import java.util.Map; public class ClientBatchCheckClientOptions implements AdditionalHeadersSupplier { @@ -49,7 +50,7 @@ public class ClientBatchCheckClientOptions implements AdditionalHeadersSupplier public ClientCheckOptions asClientCheckOptions() { return new ClientCheckOptions() - .additionalHeaders(additionalHeaders) + .additionalHeaders(additionalHeaders != null ? new HashMap<>(additionalHeaders) : null) .authorizationModelId(authorizationModelId) .consistency(consistency); } diff --git a/config/clients/java/template/src/main/api/configuration/ClientConfiguration.java.mustache b/config/clients/java/template/src/main/api/configuration/ClientConfiguration.java.mustache index 6a566442..1d6ce139 100644 --- a/config/clients/java/template/src/main/api/configuration/ClientConfiguration.java.mustache +++ b/config/clients/java/template/src/main/api/configuration/ClientConfiguration.java.mustache @@ -129,4 +129,10 @@ public class ClientConfiguration extends Configuration { super.telemetryConfiguration(telemetryConfiguration); return this; } + + @Override + public ClientConfiguration defaultHeaders(java.util.Map defaultHeaders) { + super.defaultHeaders(defaultHeaders); + return this; + } } diff --git a/config/clients/java/template/src/main/api/configuration/ClientListRelationsOptions.java.mustache b/config/clients/java/template/src/main/api/configuration/ClientListRelationsOptions.java.mustache index c1518349..c0e00663 100644 --- a/config/clients/java/template/src/main/api/configuration/ClientListRelationsOptions.java.mustache +++ b/config/clients/java/template/src/main/api/configuration/ClientListRelationsOptions.java.mustache @@ -2,6 +2,7 @@ package {{configPackage}}; import dev.openfga.sdk.api.model.ConsistencyPreference; +import java.util.HashMap; import java.util.Map; public class ClientListRelationsOptions implements AdditionalHeadersSupplier { @@ -50,6 +51,7 @@ public class ClientListRelationsOptions implements AdditionalHeadersSupplier { public ClientBatchCheckClientOptions asClientBatchCheckClientOptions() { return new ClientBatchCheckClientOptions() .authorizationModelId(authorizationModelId) + .additionalHeaders(additionalHeaders != null ? new HashMap<>(additionalHeaders) : null) .maxParallelRequests(maxParallelRequests) .consistency(consistency); } diff --git a/config/clients/java/template/src/test/api/client/OpenFgaClientHeadersTest.java.mustache b/config/clients/java/template/src/test/api/client/OpenFgaClientHeadersTest.java.mustache new file mode 100644 index 00000000..a2810a66 --- /dev/null +++ b/config/clients/java/template/src/test/api/client/OpenFgaClientHeadersTest.java.mustache @@ -0,0 +1,963 @@ +{{>licenseInfo}} +package {{clientPackage}}; + +import static org.hamcrest.Matchers.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.pgssoft.httpclient.HttpClientMock; +import {{clientPackage}}.model.*; +import {{configPackage}}.*; +import {{modelPackage}}.*; +import java.net.http.HttpClient; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Tests for OpenFgaClient header functionality. + */ +public class OpenFgaClientHeadersTest { + private static final String DEFAULT_STORE_ID = "01YCP46JKYM8FJCQ37NMBYHE5X"; + private static final String DEFAULT_STORE_NAME = "test_store"; + private static final String DEFAULT_AUTH_MODEL_ID = "01G5JAVJ41T49E9TT3SKVS7X1J"; + private static final String DEFAULT_USER = "user:81684243-9356-4421-8fbf-a4f8d36aa31b"; + private static final String DEFAULT_RELATION = "reader"; + private static final String DEFAULT_TYPE = "document"; + private static final String DEFAULT_ID = "budget"; + private static final String DEFAULT_OBJECT = DEFAULT_TYPE + ":" + DEFAULT_ID; + private static final String DEFAULT_SCHEMA_VERSION = "1.1"; + private static final String EMPTY_RESPONSE_BODY = "{}"; + private OpenFgaClient fga; + private ClientConfiguration clientConfiguration; + private HttpClientMock mockHttpClient; + + @BeforeEach + public void beforeEachTest() throws Exception { + mockHttpClient = new HttpClientMock(); + mockHttpClient.debugOn(); + + var mockHttpClientBuilder = mock(HttpClient.Builder.class); + when(mockHttpClientBuilder.executor(any())).thenReturn(mockHttpClientBuilder); + when(mockHttpClientBuilder.build()).thenReturn(mockHttpClient); + + clientConfiguration = new ClientConfiguration() + .storeId(DEFAULT_STORE_ID) + .authorizationModelId(DEFAULT_AUTH_MODEL_ID) + .apiUrl("https://api.fga.example") + .defaultHeaders(Map.of( + "test-header", "test-value", + "another-header", "another-value")); + + var mockApiClient = mock(ApiClient.class); + when(mockApiClient.getHttpClient()).thenReturn(mockHttpClient); + when(mockApiClient.getObjectMapper()).thenReturn(new ObjectMapper()); + when(mockApiClient.getHttpClientBuilder()).thenReturn(mockHttpClientBuilder); + + fga = new OpenFgaClient(clientConfiguration, mockApiClient); + } + + @Test + public void createStore_withHeaders() throws Exception { + // Given + String expectedBody = String.format("{\"name\":\"%s\"}", DEFAULT_STORE_NAME); + String requestBody = String.format("{\"id\":\"%s\",\"name\":\"%s\"}", DEFAULT_STORE_ID, DEFAULT_STORE_NAME); + mockHttpClient + .onPost("https://api.fga.example/stores") + .withBody(is(expectedBody)) + .withHeader("another-header", "another-value") + .withHeader("test-header", "test-value-per-call") + .doReturn(201, requestBody); + CreateStoreRequest request = new CreateStoreRequest().name(DEFAULT_STORE_NAME); + ClientCreateStoreOptions options = + new ClientCreateStoreOptions().additionalHeaders(Map.of("test-header", "test-value-per-call")); + + // When + CreateStoreResponse response = fga.createStore(request, options).get(); + + // Then + mockHttpClient + .verify() + .post("https://api.fga.example/stores") + .withHeader("another-header", "another-value") + .withHeader("test-header", "test-value-per-call") + .called(1); + assertEquals(DEFAULT_STORE_ID, response.getId()); + assertEquals(DEFAULT_STORE_NAME, response.getName()); + } + + @Test + public void createStore_withEmptyHeaders() throws Exception { + // Given + String expectedBody = String.format("{\"name\":\"%s\"}", DEFAULT_STORE_NAME); + String requestBody = String.format("{\"id\":\"%s\",\"name\":\"%s\"}", DEFAULT_STORE_ID, DEFAULT_STORE_NAME); + mockHttpClient + .onPost("https://api.fga.example/stores") + .withBody(is(expectedBody)) + .withHeader("another-header", "another-value") + .withHeader("test-header", "test-value") + .doReturn(201, requestBody); + CreateStoreRequest request = new CreateStoreRequest().name(DEFAULT_STORE_NAME); + ClientCreateStoreOptions options = new ClientCreateStoreOptions().additionalHeaders(Collections.emptyMap()); + + // When + CreateStoreResponse response = fga.createStore(request, options).get(); + + // Then - should use default headers only + mockHttpClient + .verify() + .post("https://api.fga.example/stores") + .withHeader("another-header", "another-value") + .withHeader("test-header", "test-value") + .called(1); + assertEquals(DEFAULT_STORE_ID, response.getId()); + } + + @Test + public void createStore_withNullHeaders() throws Exception { + // Given + String expectedBody = String.format("{\"name\":\"%s\"}", DEFAULT_STORE_NAME); + String requestBody = String.format("{\"id\":\"%s\",\"name\":\"%s\"}", DEFAULT_STORE_ID, DEFAULT_STORE_NAME); + mockHttpClient + .onPost("https://api.fga.example/stores") + .withBody(is(expectedBody)) + .withHeader("another-header", "another-value") + .withHeader("test-header", "test-value") + .doReturn(201, requestBody); + CreateStoreRequest request = new CreateStoreRequest().name(DEFAULT_STORE_NAME); + ClientCreateStoreOptions options = new ClientCreateStoreOptions().additionalHeaders(null); + + // When + CreateStoreResponse response = fga.createStore(request, options).get(); + + // Then - should use default headers only + mockHttpClient + .verify() + .post("https://api.fga.example/stores") + .withHeader("another-header", "another-value") + .withHeader("test-header", "test-value") + .called(1); + assertEquals(DEFAULT_STORE_ID, response.getId()); + } + + @Test + public void createStore_withNewHeaders() throws Exception { + // Given + String expectedBody = String.format("{\"name\":\"%s\"}", DEFAULT_STORE_NAME); + String requestBody = String.format("{\"id\":\"%s\",\"name\":\"%s\"}", DEFAULT_STORE_ID, DEFAULT_STORE_NAME); + mockHttpClient + .onPost("https://api.fga.example/stores") + .withBody(is(expectedBody)) + .withHeader("another-header", "another-value") + .withHeader("test-header", "test-value") + .withHeader("new-header", "new-value") + .doReturn(201, requestBody); + CreateStoreRequest request = new CreateStoreRequest().name(DEFAULT_STORE_NAME); + ClientCreateStoreOptions options = + new ClientCreateStoreOptions().additionalHeaders(Map.of("new-header", "new-value")); + + // When + CreateStoreResponse response = fga.createStore(request, options).get(); + + // Then - should include both default and new headers + mockHttpClient + .verify() + .post("https://api.fga.example/stores") + .withHeader("another-header", "another-value") + .withHeader("test-header", "test-value") + .withHeader("new-header", "new-value") + .called(1); + assertEquals(DEFAULT_STORE_ID, response.getId()); + } + + @Test + public void listStores_withHeaders() throws Exception { + // Given + String responseBody = + String.format("{\"stores\":[{\"id\":\"%s\",\"name\":\"%s\"}]}", DEFAULT_STORE_ID, DEFAULT_STORE_NAME); + mockHttpClient + .onGet("https://api.fga.example/stores") + .withHeader("another-header", "another-value") + .withHeader("test-header", "test-value-per-call") + .doReturn(200, responseBody); + ClientListStoresOptions options = + new ClientListStoresOptions().additionalHeaders(Map.of("test-header", "test-value-per-call")); + + // When + ClientListStoresResponse response = fga.listStores(options).get(); + + // Then + mockHttpClient + .verify() + .get("https://api.fga.example/stores") + .withHeader("another-header", "another-value") + .withHeader("test-header", "test-value-per-call") + .called(1); + assertNotNull(response.getStores()); + assertEquals(1, response.getStores().size()); + } + + @Test + public void getStore_withHeaders() throws Exception { + // Given + String getUrl = String.format("https://api.fga.example/stores/%s", DEFAULT_STORE_ID); + String responseBody = String.format("{\"id\":\"%s\",\"name\":\"%s\"}", DEFAULT_STORE_ID, DEFAULT_STORE_NAME); + mockHttpClient + .onGet(getUrl) + .withHeader("another-header", "another-value") + .withHeader("test-header", "test-value-per-call") + .doReturn(200, responseBody); + ClientGetStoreOptions options = + new ClientGetStoreOptions().additionalHeaders(Map.of("test-header", "test-value-per-call")); + + // When + ClientGetStoreResponse response = fga.getStore(options).get(); + + // Then + mockHttpClient + .verify() + .get(getUrl) + .withHeader("another-header", "another-value") + .withHeader("test-header", "test-value-per-call") + .called(1); + assertEquals(DEFAULT_STORE_ID, response.getId()); + } + + @Test + public void deleteStore_withHeaders() throws Exception { + // Given + String deleteUrl = String.format("https://api.fga.example/stores/%s", DEFAULT_STORE_ID); + mockHttpClient + .onDelete(deleteUrl) + .withHeader("another-header", "another-value") + .withHeader("test-header", "test-value-per-call") + .doReturn(204, EMPTY_RESPONSE_BODY); + ClientDeleteStoreOptions options = + new ClientDeleteStoreOptions().additionalHeaders(Map.of("test-header", "test-value-per-call")); + + // When + ClientDeleteStoreResponse response = fga.deleteStore(options).get(); + + // Then + mockHttpClient + .verify() + .delete(deleteUrl) + .withHeader("another-header", "another-value") + .withHeader("test-header", "test-value-per-call") + .called(1); + assertEquals(204, response.getStatusCode()); + } + + @Test + public void readAuthorizationModels_withHeaders() throws Exception { + // Given + String getUrl = String.format("https://api.fga.example/stores/%s/authorization-models", DEFAULT_STORE_ID); + String responseBody = String.format( + "{\"authorization_models\":[{\"id\":\"%s\",\"schema_version\":\"%s\"}]}", + DEFAULT_AUTH_MODEL_ID, DEFAULT_SCHEMA_VERSION); + mockHttpClient + .onGet(getUrl) + .withHeader("another-header", "another-value") + .withHeader("test-header", "test-value-per-call") + .doReturn(200, responseBody); + ClientReadAuthorizationModelsOptions options = new ClientReadAuthorizationModelsOptions() + .additionalHeaders(Map.of("test-header", "test-value-per-call")); + + // When + ClientReadAuthorizationModelsResponse response = + fga.readAuthorizationModels(options).get(); + + // Then + mockHttpClient + .verify() + .get(getUrl) + .withHeader("another-header", "another-value") + .withHeader("test-header", "test-value-per-call") + .called(1); + assertNotNull(response.getAuthorizationModels()); + assertEquals(1, response.getAuthorizationModels().size()); + } + + @Test + public void writeAuthorizationModel_withHeaders() throws Exception { + // Given + String postUrl = String.format("https://api.fga.example/stores/%s/authorization-models", DEFAULT_STORE_ID); + String expectedBody = + "{\"type_definitions\":[{\"type\":\"document\",\"relations\":{},\"metadata\":null}],\"schema_version\":\"1.1\",\"conditions\":{}}"; + String responseBody = String.format("{\"authorization_model_id\":\"%s\"}", DEFAULT_AUTH_MODEL_ID); + mockHttpClient + .onPost(postUrl) + .withBody(is(expectedBody)) + .withHeader("another-header", "another-value") + .withHeader("test-header", "test-value-per-call") + .doReturn(201, responseBody); + WriteAuthorizationModelRequest request = new WriteAuthorizationModelRequest() + .schemaVersion(DEFAULT_SCHEMA_VERSION) + .typeDefinitions(List.of(new TypeDefinition().type(DEFAULT_TYPE))); + ClientWriteAuthorizationModelOptions options = new ClientWriteAuthorizationModelOptions() + .additionalHeaders(Map.of("test-header", "test-value-per-call")); + + // When + ClientWriteAuthorizationModelResponse response = + fga.writeAuthorizationModel(request, options).get(); + + // Then + mockHttpClient + .verify() + .post(postUrl) + .withHeader("another-header", "another-value") + .withHeader("test-header", "test-value-per-call") + .called(1); + assertEquals(DEFAULT_AUTH_MODEL_ID, response.getAuthorizationModelId()); + } + + @Test + public void readAuthorizationModel_withHeaders() throws Exception { + // Given + String getUrl = String.format( + "https://api.fga.example/stores/%s/authorization-models/%s", DEFAULT_STORE_ID, DEFAULT_AUTH_MODEL_ID); + String getResponse = String.format( + "{\"authorization_model\":{\"id\":\"%s\",\"schema_version\":\"%s\"}}", + DEFAULT_AUTH_MODEL_ID, DEFAULT_SCHEMA_VERSION); + mockHttpClient + .onGet(getUrl) + .withHeader("another-header", "another-value") + .withHeader("test-header", "test-value-per-call") + .doReturn(200, getResponse); + ClientReadAuthorizationModelOptions options = new ClientReadAuthorizationModelOptions() + .additionalHeaders(Map.of("test-header", "test-value-per-call")); + + // When + ClientReadAuthorizationModelResponse response = + fga.readAuthorizationModel(options).get(); + + // Then + mockHttpClient + .verify() + .get(getUrl) + .withHeader("another-header", "another-value") + .withHeader("test-header", "test-value-per-call") + .called(1); + assertNotNull(response.getAuthorizationModel()); + assertEquals(DEFAULT_AUTH_MODEL_ID, response.getAuthorizationModel().getId()); + } + + @Test + public void read_withHeaders() throws Exception { + // Given + String postUrl = String.format("https://api.fga.example/stores/%s/read", DEFAULT_STORE_ID); + String expectedBody = String.format( + "{\"tuple_key\":{\"user\":\"%s\",\"relation\":\"%s\",\"object\":\"%s\"},\"page_size\":null,\"continuation_token\":null,\"consistency\":\"UNSPECIFIED\"}", + DEFAULT_USER, DEFAULT_RELATION, DEFAULT_OBJECT); + String responseBody = String.format( + "{\"tuples\":[{\"key\":{\"user\":\"%s\",\"relation\":\"%s\",\"object\":\"%s\"}}]}", + DEFAULT_USER, DEFAULT_RELATION, DEFAULT_OBJECT); + mockHttpClient + .onPost(postUrl) + .withBody(is(expectedBody)) + .withHeader("another-header", "another-value") + .withHeader("test-header", "test-value-per-call") + .doReturn(200, responseBody); + ClientReadRequest request = new ClientReadRequest() + .user(DEFAULT_USER) + .relation(DEFAULT_RELATION) + ._object(DEFAULT_OBJECT); + ClientReadOptions options = + new ClientReadOptions().additionalHeaders(Map.of("test-header", "test-value-per-call")); + + // When + ClientReadResponse response = fga.read(request, options).get(); + + // Then + mockHttpClient + .verify() + .post(postUrl) + .withHeader("another-header", "another-value") + .withHeader("test-header", "test-value-per-call") + .called(1); + assertNotNull(response.getTuples()); + assertEquals(1, response.getTuples().size()); + } + + @Test + public void write_withHeaders() throws Exception { + // Given + String postPath = String.format("https://api.fga.example/stores/%s/write", DEFAULT_STORE_ID); + String expectedBody = String.format( + "{\"writes\":{\"tuple_keys\":[{\"user\":\"%s\",\"relation\":\"%s\",\"object\":\"%s\",\"condition\":null}]},\"deletes\":null,\"authorization_model_id\":\"%s\"}", + DEFAULT_USER, DEFAULT_RELATION, DEFAULT_OBJECT, DEFAULT_AUTH_MODEL_ID); + mockHttpClient + .onPost(postPath) + .withBody(is(expectedBody)) + .withHeader("another-header", "another-value") + .withHeader("test-header", "test-value-per-call") + .doReturn(200, EMPTY_RESPONSE_BODY); + ClientWriteRequest request = new ClientWriteRequest() + .writes(List.of(new ClientTupleKey() + ._object(DEFAULT_OBJECT) + .relation(DEFAULT_RELATION) + .user(DEFAULT_USER))); + ClientWriteOptions options = + new ClientWriteOptions().additionalHeaders(Map.of("test-header", "test-value-per-call")); + + // When + ClientWriteResponse response = fga.write(request, options).get(); + + // Then + mockHttpClient + .verify() + .post(postPath) + .withHeader("another-header", "another-value") + .withHeader("test-header", "test-value-per-call") + .called(1); + assertEquals(200, response.getStatusCode()); + } + + @Test + public void writeNonTransaction_withHeaders() throws Exception { + // Given + String postPath = String.format("https://api.fga.example/stores/%s/write", DEFAULT_STORE_ID); + String expectedBody = String.format( + "{\"writes\":{\"tuple_keys\":[{\"user\":\"%s\",\"relation\":\"%s\",\"object\":\"%s\",\"condition\":null}]},\"deletes\":null,\"authorization_model_id\":\"%s\"}", + DEFAULT_USER, DEFAULT_RELATION, DEFAULT_OBJECT, DEFAULT_AUTH_MODEL_ID); + mockHttpClient + .onPost(postPath) + .withBody(is(expectedBody)) + .withHeader("another-header", "another-value") + .withHeader("test-header", "test-value-per-call") + .doReturn(200, EMPTY_RESPONSE_BODY); + ClientWriteRequest request = new ClientWriteRequest() + .writes(List.of(new ClientTupleKey() + ._object(DEFAULT_OBJECT) + .relation(DEFAULT_RELATION) + .user(DEFAULT_USER))); + ClientWriteOptions options = new ClientWriteOptions() + .additionalHeaders(Map.of("test-header", "test-value-per-call")) + .disableTransactions(true); + + // When + ClientWriteResponse response = fga.write(request, options).get(); + + // Then + mockHttpClient + .verify() + .post(postPath) + .withHeader("another-header", "another-value") + .withHeader("test-header", "test-value-per-call") + .called(1); + assertEquals(200, response.getStatusCode()); + } + + @Test + public void check_withHeaders() throws Exception { + // Given + String postUrl = String.format("https://api.fga.example/stores/%s/check", DEFAULT_STORE_ID); + String expectedBody = String.format( + "{\"tuple_key\":{\"user\":\"%s\",\"relation\":\"%s\",\"object\":\"%s\"},\"contextual_tuples\":null,\"authorization_model_id\":\"%s\",\"trace\":null,\"context\":null,\"consistency\":\"UNSPECIFIED\"}", + DEFAULT_USER, DEFAULT_RELATION, DEFAULT_OBJECT, DEFAULT_AUTH_MODEL_ID); + mockHttpClient + .onPost(postUrl) + .withBody(is(expectedBody)) + .withHeader("another-header", "another-value") + .withHeader("test-header", "test-value-per-call") + .doReturn(200, "{\"allowed\":true}"); + ClientCheckRequest request = new ClientCheckRequest() + ._object(DEFAULT_OBJECT) + .relation(DEFAULT_RELATION) + .user(DEFAULT_USER); + ClientCheckOptions options = + new ClientCheckOptions().additionalHeaders(Map.of("test-header", "test-value-per-call")); + + // When + ClientCheckResponse response = fga.check(request, options).get(); + + // Then + mockHttpClient + .verify() + .post(postUrl) + .withHeader("another-header", "another-value") + .withHeader("test-header", "test-value-per-call") + .called(1); + assertEquals(Boolean.TRUE, response.getAllowed()); + } + + @Test + public void batchCheck_withHeaders() throws Exception { + // Given + String postUrl = String.format("https://api.fga.example/stores/%s/batch-check", DEFAULT_STORE_ID); + String expectedBody = String.format( + "{\"checks\":[{\"tuple_key\":{\"user\":\"%s\",\"relation\":\"%s\",\"object\":\"%s\"},\"contextual_tuples\":null,\"context\":null,\"correlation_id\":\"cor-1\"}],\"authorization_model_id\":\"%s\",\"consistency\":\"UNSPECIFIED\"}", + DEFAULT_USER, DEFAULT_RELATION, DEFAULT_OBJECT, DEFAULT_AUTH_MODEL_ID); + mockHttpClient + .onPost(postUrl) + .withBody(is(expectedBody)) + .withHeader("another-header", "another-value") + .withHeader("test-header", "test-value-per-call") + .doReturn(200, "{\"result\":{}}"); + ClientBatchCheckItem item = new ClientBatchCheckItem() + .user(DEFAULT_USER) + .relation(DEFAULT_RELATION) + ._object(DEFAULT_OBJECT) + .correlationId("cor-1"); + ClientBatchCheckRequest request = new ClientBatchCheckRequest().checks(List.of(item)); + ClientBatchCheckOptions options = + new ClientBatchCheckOptions().additionalHeaders(Map.of("test-header", "test-value-per-call")); + + // When + ClientBatchCheckResponse response = fga.batchCheck(request, options).join(); + + // Then + mockHttpClient + .verify() + .post(postUrl) + .withHeader("another-header", "another-value") + .withHeader("test-header", "test-value-per-call") + .called(1); + assertNotNull(response); + assertTrue(response.getResult().isEmpty()); + } + + @Test + public void clientBatchCheck_withHeaders() throws Exception { + // Given + String postUrl = String.format("https://api.fga.example/stores/%s/check", DEFAULT_STORE_ID); + String expectedBody = String.format( + "{\"tuple_key\":{\"user\":\"%s\",\"relation\":\"%s\",\"object\":\"%s\"},\"contextual_tuples\":null,\"authorization_model_id\":\"%s\",\"trace\":null,\"context\":null,\"consistency\":\"UNSPECIFIED\"}", + DEFAULT_USER, DEFAULT_RELATION, DEFAULT_OBJECT, DEFAULT_AUTH_MODEL_ID); + mockHttpClient + .onPost(postUrl) + .withBody(is(expectedBody)) + .withHeader("another-header", "another-value") + .withHeader("test-header", "test-value-per-call") + .doReturn(200, "{\"allowed\":true}"); + ClientCheckRequest request = new ClientCheckRequest() + ._object(DEFAULT_OBJECT) + .relation(DEFAULT_RELATION) + .user(DEFAULT_USER); + ClientBatchCheckClientOptions options = + new ClientBatchCheckClientOptions().additionalHeaders(Map.of("test-header", "test-value-per-call")); + + // When + List response = + fga.clientBatchCheck(List.of(request), options).get(); + + // Then + mockHttpClient + .verify() + .post(postUrl) + .withBody(is(expectedBody)) + .withHeader("another-header", "another-value") + .withHeader("test-header", "test-value-per-call") + .called(1); + assertEquals(Boolean.TRUE, response.get(0).getAllowed()); + } + + @Test + public void expand_withHeaders() throws Exception { + // Given + String postPath = String.format("https://api.fga.example/stores/%s/expand", DEFAULT_STORE_ID); + String expectedBody = String.format( + "{\"tuple_key\":{\"relation\":\"%s\",\"object\":\"%s\"},\"authorization_model_id\":\"%s\",\"consistency\":\"UNSPECIFIED\",\"contextual_tuples\":null}", + DEFAULT_RELATION, DEFAULT_OBJECT, DEFAULT_AUTH_MODEL_ID); + String responseBody = String.format( + "{\"tree\":{\"root\":{\"union\":{\"nodes\":[{\"leaf\":{\"users\":{\"users\":[\"%s\"]}}}]}}}}", + DEFAULT_USER); + mockHttpClient + .onPost(postPath) + .withBody(is(expectedBody)) + .withHeader("another-header", "another-value") + .withHeader("test-header", "test-value-per-call") + .doReturn(200, responseBody); + ClientExpandRequest request = + new ClientExpandRequest().relation(DEFAULT_RELATION)._object(DEFAULT_OBJECT); + ClientExpandOptions options = + new ClientExpandOptions().additionalHeaders(Map.of("test-header", "test-value-per-call")); + + // When + ClientExpandResponse response = fga.expand(request, options).get(); + + // Then + mockHttpClient + .verify() + .post(postPath) + .withHeader("another-header", "another-value") + .withHeader("test-header", "test-value-per-call") + .called(1); + assertNotNull(response.getTree()); + } + + @Test + public void listObjects_withHeaders() throws Exception { + // Given + String postPath = String.format("https://api.fga.example/stores/%s/list-objects", DEFAULT_STORE_ID); + String expectedBody = String.format( + "{\"authorization_model_id\":\"%s\",\"type\":null,\"relation\":\"%s\",\"user\":\"%s\",\"contextual_tuples\":null,\"context\":null,\"consistency\":\"UNSPECIFIED\"}", + DEFAULT_AUTH_MODEL_ID, DEFAULT_RELATION, DEFAULT_USER); + mockHttpClient + .onPost(postPath) + .withBody(is(expectedBody)) + .withHeader("another-header", "another-value") + .withHeader("test-header", "test-value-per-call") + .doReturn(200, String.format("{\"objects\":[\"%s\"]}", DEFAULT_OBJECT)); + ClientListObjectsRequest request = + new ClientListObjectsRequest().relation(DEFAULT_RELATION).user(DEFAULT_USER); + ClientListObjectsOptions options = + new ClientListObjectsOptions().additionalHeaders(Map.of("test-header", "test-value-per-call")); + + // When + ClientListObjectsResponse response = fga.listObjects(request, options).get(); + + // Then + mockHttpClient + .verify() + .post(postPath) + .withHeader("another-header", "another-value") + .withHeader("test-header", "test-value-per-call") + .called(1); + assertEquals(List.of(DEFAULT_OBJECT), response.getObjects()); + } + + @Test + public void listUsers_withHeaders() throws Exception { + // Given + String postPath = String.format("https://api.fga.example/stores/%s/list-users", DEFAULT_STORE_ID); + String expectedBody = String.format( + "{\"authorization_model_id\":\"%s\",\"object\":{\"type\":\"%s\",\"id\":\"%s\"},\"relation\":\"%s\",\"user_filters\":null,\"contextual_tuples\":[],\"context\":null,\"consistency\":\"UNSPECIFIED\"}", + DEFAULT_AUTH_MODEL_ID, DEFAULT_TYPE, DEFAULT_ID, DEFAULT_RELATION); + mockHttpClient + .onPost(postPath) + .withBody(is(expectedBody)) + .withHeader("another-header", "another-value") + .withHeader("test-header", "test-value-per-call") + .doReturn(200, "{\"users\":[]}"); + ClientListUsersRequest request = new ClientListUsersRequest() + ._object(new FgaObject().type(DEFAULT_TYPE).id(DEFAULT_ID)) + .relation(DEFAULT_RELATION); + ClientListUsersOptions options = + new ClientListUsersOptions().additionalHeaders(Map.of("test-header", "test-value-per-call")); + + // When + ClientListUsersResponse response = fga.listUsers(request, options).get(); + + // Then + mockHttpClient + .verify() + .post(postPath) + .withHeader("another-header", "another-value") + .withHeader("test-header", "test-value-per-call") + .called(1); + assertNotNull(response.getUsers()); + } + + @Test + public void readAssertions_withHeaders() throws Exception { + // Given + String getUrl = String.format( + "https://api.fga.example/stores/%s/assertions/%s", DEFAULT_STORE_ID, DEFAULT_AUTH_MODEL_ID); + String responseBody = String.format( + "{\"assertions\":[{\"tuple_key\":{\"user\":\"%s\",\"relation\":\"%s\",\"object\":\"%s\"},\"expectation\":true}]}", + DEFAULT_USER, DEFAULT_RELATION, DEFAULT_OBJECT); + mockHttpClient + .onGet(getUrl) + .withHeader("another-header", "another-value") + .withHeader("test-header", "test-value-per-call") + .doReturn(200, responseBody); + ClientReadAssertionsOptions options = + new ClientReadAssertionsOptions().additionalHeaders(Map.of("test-header", "test-value-per-call")); + + // When + ClientReadAssertionsResponse response = fga.readAssertions(options).get(); + + // Then + mockHttpClient + .verify() + .get(getUrl) + .withHeader("another-header", "another-value") + .withHeader("test-header", "test-value-per-call") + .called(1); + assertNotNull(response.getAssertions()); + assertEquals(1, response.getAssertions().size()); + } + + @Test + public void writeAssertions_withHeaders() throws Exception { + // Given + String putUrl = String.format( + "https://api.fga.example/stores/%s/assertions/%s", DEFAULT_STORE_ID, DEFAULT_AUTH_MODEL_ID); + String expectedBody = String.format( + "{\"assertions\":[{\"tuple_key\":{\"object\":\"%s\",\"relation\":\"%s\",\"user\":\"%s\"},\"expectation\":true,\"contextual_tuples\":[],\"context\":null}]}", + DEFAULT_OBJECT, DEFAULT_RELATION, DEFAULT_USER); + mockHttpClient + .onPut(putUrl) + .withBody(is(expectedBody)) + .withHeader("another-header", "another-value") + .withHeader("test-header", "test-value-per-call") + .doReturn(200, EMPTY_RESPONSE_BODY); + List assertions = List.of(new ClientAssertion() + .user(DEFAULT_USER) + .relation(DEFAULT_RELATION) + ._object(DEFAULT_OBJECT) + .expectation(true)); + ClientWriteAssertionsOptions options = + new ClientWriteAssertionsOptions().additionalHeaders(Map.of("test-header", "test-value-per-call")); + + // When + ClientWriteAssertionsResponse response = + fga.writeAssertions(assertions, options).get(); + + // Then + mockHttpClient + .verify() + .put(putUrl) + .withHeader("another-header", "another-value") + .withHeader("test-header", "test-value-per-call") + .called(1); + assertEquals(200, response.getStatusCode()); + } + + @Test + public void readLatestAuthorizationModel_withHeaders() throws Exception { + // Given + String getUrl = + String.format("https://api.fga.example/stores/%s/authorization-models?page_size=1", DEFAULT_STORE_ID); + String responseBody = String.format( + "{\"authorization_models\":[{\"id\":\"%s\",\"schema_version\":\"%s\"}]}", + DEFAULT_AUTH_MODEL_ID, DEFAULT_SCHEMA_VERSION); + mockHttpClient + .onGet(getUrl) + .withHeader("another-header", "another-value") + .withHeader("test-header", "test-value-per-call") + .doReturn(200, responseBody); + ClientReadLatestAuthorizationModelOptions options = new ClientReadLatestAuthorizationModelOptions() + .additionalHeaders(Map.of("test-header", "test-value-per-call")); + + // When + ClientReadAuthorizationModelResponse response = + fga.readLatestAuthorizationModel(options).get(); + + // Then + mockHttpClient + .verify() + .get(getUrl) + .withHeader("another-header", "another-value") + .withHeader("test-header", "test-value-per-call") + .called(1); + assertNotNull(response.getAuthorizationModel()); + assertEquals(DEFAULT_AUTH_MODEL_ID, response.getAuthorizationModel().getId()); + } + + @Test + public void listRelations_withHeaders() throws Exception { + // Given + String postUrl = String.format("https://api.fga.example/stores/%s/check", DEFAULT_STORE_ID); + String expectedBody = String.format( + "{\"tuple_key\":{\"user\":\"%s\",\"relation\":\"%s\",\"object\":\"%s\"},\"contextual_tuples\":null,\"authorization_model_id\":\"%s\",\"trace\":null,\"context\":null,\"consistency\":\"UNSPECIFIED\"}", + DEFAULT_USER, DEFAULT_RELATION, DEFAULT_OBJECT, DEFAULT_AUTH_MODEL_ID); + + mockHttpClient + .onPost(postUrl) + .withBody(is(expectedBody)) + .withHeader("another-header", "another-value") + .withHeader("test-header", "test-value-per-call") + .doReturn(200, "{\"allowed\":true}"); + + ClientListRelationsRequest request = new ClientListRelationsRequest() + .relations(List.of(DEFAULT_RELATION)) + .user(DEFAULT_USER) + ._object(DEFAULT_OBJECT); + ClientListRelationsOptions options = + new ClientListRelationsOptions().additionalHeaders(Map.of("test-header", "test-value-per-call")); + + // When + ClientListRelationsResponse response = + fga.listRelations(request, options).get(); + + mockHttpClient + .verify() + .post(postUrl) + .withHeader("another-header", "another-value") + .withHeader("test-header", "test-value-per-call") + .called(1); + } + + @Test + public void listRelations_withNullHeaders() throws Exception { + // Given + String postUrl = String.format("https://api.fga.example/stores/%s/check", DEFAULT_STORE_ID); + String expectedBody = String.format( + "{\"tuple_key\":{\"user\":\"%s\",\"relation\":\"%s\",\"object\":\"%s\"},\"contextual_tuples\":null,\"authorization_model_id\":\"%s\",\"trace\":null,\"context\":null,\"consistency\":\"UNSPECIFIED\"}", + DEFAULT_USER, DEFAULT_RELATION, DEFAULT_OBJECT, DEFAULT_AUTH_MODEL_ID); + + mockHttpClient + .onPost(postUrl) + .withBody(is(expectedBody)) + .withHeader("another-header", "another-value") + .withHeader("test-header", "test-value") + .doReturn(200, "{\"allowed\":true}"); + + ClientListRelationsRequest request = new ClientListRelationsRequest() + .relations(List.of(DEFAULT_RELATION)) + .user(DEFAULT_USER) + ._object(DEFAULT_OBJECT); + ClientListRelationsOptions options = new ClientListRelationsOptions().additionalHeaders(null); + + // When - this should not throw even though additionalHeaders is null + ClientListRelationsResponse response = + fga.listRelations(request, options).get(); + + // Then + mockHttpClient + .verify() + .post(postUrl) + .withHeader("another-header", "another-value") + .withHeader("test-header", "test-value") + .called(1); + assertNotNull(response); + } + + @Test + public void clientBatchCheck_withNullHeaders() throws Exception { + // Given + String postUrl = String.format("https://api.fga.example/stores/%s/check", DEFAULT_STORE_ID); + String expectedBody = String.format( + "{\"tuple_key\":{\"user\":\"%s\",\"relation\":\"%s\",\"object\":\"%s\"},\"contextual_tuples\":null,\"authorization_model_id\":\"%s\",\"trace\":null,\"context\":null,\"consistency\":\"UNSPECIFIED\"}", + DEFAULT_USER, DEFAULT_RELATION, DEFAULT_OBJECT, DEFAULT_AUTH_MODEL_ID); + mockHttpClient + .onPost(postUrl) + .withBody(is(expectedBody)) + .withHeader("another-header", "another-value") + .withHeader("test-header", "test-value") + .doReturn(200, "{\"allowed\":true}"); + ClientCheckRequest request = new ClientCheckRequest() + ._object(DEFAULT_OBJECT) + .relation(DEFAULT_RELATION) + .user(DEFAULT_USER); + ClientBatchCheckClientOptions options = new ClientBatchCheckClientOptions().additionalHeaders(null); + + // When - this should not throw even though additionalHeaders is null + List response = + fga.clientBatchCheck(List.of(request), options).get(); + + // Then + mockHttpClient + .verify() + .post(postUrl) + .withBody(is(expectedBody)) + .withHeader("another-header", "another-value") + .withHeader("test-header", "test-value") + .called(1); + assertEquals(Boolean.TRUE, response.get(0).getAllowed()); + } + + /** + * Edge case: No default headers configured on client. + */ + @Test + public void createStore_withNoDefaultHeaders() throws Exception { + // Given - reconfigure without default headers + clientConfiguration.defaultHeaders(null); + fga.setConfiguration(clientConfiguration); + + String expectedBody = String.format("{\"name\":\"%s\"}", DEFAULT_STORE_NAME); + String requestBody = String.format("{\"id\":\"%s\",\"name\":\"%s\"}", DEFAULT_STORE_ID, DEFAULT_STORE_NAME); + mockHttpClient + .onPost("https://api.fga.example/stores") + .withBody(is(expectedBody)) + .withHeader("per-call-header", "per-call-value") + .doReturn(201, requestBody); + CreateStoreRequest request = new CreateStoreRequest().name(DEFAULT_STORE_NAME); + ClientCreateStoreOptions options = + new ClientCreateStoreOptions().additionalHeaders(Map.of("per-call-header", "per-call-value")); + + // When + CreateStoreResponse response = fga.createStore(request, options).get(); + + // Then - should only have per-call headers + mockHttpClient + .verify() + .post("https://api.fga.example/stores") + .withHeader("per-call-header", "per-call-value") + .called(1); + assertEquals(DEFAULT_STORE_ID, response.getId()); + } + + /** + * Edge case: Multiple headers with same key override correctly. + */ + @Test + public void write_multipleOverrides() throws Exception { + // Given + String postPath = String.format("https://api.fga.example/stores/%s/write", DEFAULT_STORE_ID); + String expectedBody = String.format( + "{\"writes\":{\"tuple_keys\":[{\"user\":\"%s\",\"relation\":\"%s\",\"object\":\"%s\",\"condition\":null}]},\"deletes\":null,\"authorization_model_id\":\"%s\"}", + DEFAULT_USER, DEFAULT_RELATION, DEFAULT_OBJECT, DEFAULT_AUTH_MODEL_ID); + mockHttpClient + .onPost(postPath) + .withBody(is(expectedBody)) + .withHeader("test-header", "override-3") + .withHeader("another-header", "override-2") + .doReturn(200, EMPTY_RESPONSE_BODY); + ClientWriteRequest request = new ClientWriteRequest() + .writes(List.of(new ClientTupleKey() + ._object(DEFAULT_OBJECT) + .relation(DEFAULT_RELATION) + .user(DEFAULT_USER))); + ClientWriteOptions options = new ClientWriteOptions() + .additionalHeaders(Map.of("test-header", "override-3", "another-header", "override-2")); + + // When + ClientWriteResponse response = fga.write(request, options).get(); + + // Then - all headers should be overridden + mockHttpClient + .verify() + .post(postPath) + .withHeader("test-header", "override-3") + .withHeader("another-header", "override-2") + .called(1); + assertEquals(200, response.getStatusCode()); + } + + /** + * Edge case: Special characters in header values. + */ + @Test + public void check_withSpecialCharactersInHeaders() throws Exception { + // Given + String postUrl = String.format("https://api.fga.example/stores/%s/check", DEFAULT_STORE_ID); + String expectedBody = String.format( + "{\"tuple_key\":{\"user\":\"%s\",\"relation\":\"%s\",\"object\":\"%s\"},\"contextual_tuples\":null,\"authorization_model_id\":\"%s\",\"trace\":null,\"context\":null,\"consistency\":\"UNSPECIFIED\"}", + DEFAULT_USER, DEFAULT_RELATION, DEFAULT_OBJECT, DEFAULT_AUTH_MODEL_ID); + mockHttpClient + .onPost(postUrl) + .withBody(is(expectedBody)) + .withHeader("another-header", "another-value") + .withHeader("test-header", "value-with-dashes_and_underscores") + .withHeader("x-custom", "UTF-8,gzip,deflate") + .doReturn(200, "{\"allowed\":true}"); + ClientCheckRequest request = new ClientCheckRequest() + ._object(DEFAULT_OBJECT) + .relation(DEFAULT_RELATION) + .user(DEFAULT_USER); + ClientCheckOptions options = new ClientCheckOptions() + .additionalHeaders(Map.of( + "test-header", "value-with-dashes_and_underscores", + "x-custom", "UTF-8,gzip,deflate")); + + // When + ClientCheckResponse response = fga.check(request, options).get(); + + // Then + mockHttpClient + .verify() + .post(postUrl) + .withHeader("test-header", "value-with-dashes_and_underscores") + .withHeader("x-custom", "UTF-8,gzip,deflate") + .called(1); + assertEquals(Boolean.TRUE, response.getAllowed()); + } +} diff --git a/config/clients/java/template/src/test/api/client/OpenFgaClientTest.java.mustache b/config/clients/java/template/src/test/api/client/OpenFgaClientTest.java.mustache index cb062bce..60bab259 100644 --- a/config/clients/java/template/src/test/api/client/OpenFgaClientTest.java.mustache +++ b/config/clients/java/template/src/test/api/client/OpenFgaClientTest.java.mustache @@ -2400,7 +2400,7 @@ public class OpenFgaClientTest { mockHttpClient .onPost(postUrl) .withBody(is(expectedBody)) - .withHeader(CLIENT_METHOD_HEADER, "ClientBatchCheck") + .withHeader(CLIENT_METHOD_HEADER, "ListRelations") .withHeader(CLIENT_BULK_REQUEST_ID_HEADER, anyValidUUID()) .doReturn(200, "{\"allowed\":true}"); ClientListRelationsRequest request = new ClientListRelationsRequest() @@ -2420,7 +2420,7 @@ public class OpenFgaClientTest { .verify() .post(postUrl) .withBody(is(expectedBody)) - .withHeader(CLIENT_METHOD_HEADER, "ClientBatchCheck") + .withHeader(CLIENT_METHOD_HEADER, "ListRelations") .withHeader(CLIENT_BULK_REQUEST_ID_HEADER, anyValidUUID()) .called(1); assertNotNull(response); @@ -2439,7 +2439,7 @@ public class OpenFgaClientTest { mockHttpClient .onPost(postUrl) .withBody(is(expectedBody)) - .withHeader(CLIENT_METHOD_HEADER, "ClientBatchCheck") + .withHeader(CLIENT_METHOD_HEADER, "ListRelations") .withHeader(CLIENT_BULK_REQUEST_ID_HEADER, anyValidUUID()) .doReturn(200, "{\"allowed\":false}"); ClientListRelationsRequest request = new ClientListRelationsRequest() @@ -2458,7 +2458,7 @@ public class OpenFgaClientTest { .verify() .post(postUrl) .withBody(is(expectedBody)) - .withHeader(CLIENT_METHOD_HEADER, "ClientBatchCheck") + .withHeader(CLIENT_METHOD_HEADER, "ListRelations") .withHeader(CLIENT_BULK_REQUEST_ID_HEADER, anyValidUUID()) .called(1); assertNotNull(response); @@ -2627,7 +2627,7 @@ public class OpenFgaClientTest { mockHttpClient .onPost(postUrl) .withBody(is(expectedBody)) - .withHeader(CLIENT_METHOD_HEADER, "ClientBatchCheck") + .withHeader(CLIENT_METHOD_HEADER, "ListRelations") .withHeader(CLIENT_BULK_REQUEST_ID_HEADER, anyValidUUID()) .doReturn(200, "{\"allowed\":false}"); ClientListRelationsRequest request = new ClientListRelationsRequest() @@ -2651,7 +2651,7 @@ public class OpenFgaClientTest { .verify() .post(postUrl) .withBody(is(expectedBody)) - .withHeader(CLIENT_METHOD_HEADER, "ClientBatchCheck") + .withHeader(CLIENT_METHOD_HEADER, "ListRelations") .withHeader(CLIENT_BULK_REQUEST_ID_HEADER, anyValidUUID()) .called(1); assertNotNull(response); diff --git a/config/common/files/README.mustache b/config/common/files/README.mustache index 439e3ea1..6ce0cf51 100644 --- a/config/common/files/README.mustache +++ b/config/common/files/README.mustache @@ -17,6 +17,7 @@ - [Installation](#installation) - [Getting Started](#getting-started) - [Initializing the API Client](#initializing-the-api-client) + - [Custom Headers](#custom-headers) - [Get your Store ID](#get-your-store-id) - [Calling the API](#calling-the-api) - [Stores](#stores) From e056e037559d039c866aaa08dc71b470ccbc8173 Mon Sep 17 00:00:00 2001 From: Ewan Harris Date: Tue, 7 Oct 2025 11:37:11 +0100 Subject: [PATCH 2/3] chore(java-sdk): use jackson bom Co-authored-by: Jim Anderson --- .../java/template/build.gradle.mustache | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/config/clients/java/template/build.gradle.mustache b/config/clients/java/template/build.gradle.mustache index 9a15b3db..097a81d9 100644 --- a/config/clients/java/template/build.gradle.mustache +++ b/config/clients/java/template/build.gradle.mustache @@ -61,7 +61,7 @@ ext { {{#swagger2AnnotationLibrary}} swagger_annotations_version = "2.2.9" {{/swagger2AnnotationLibrary}} - jackson_version = "2.19.2" + jackson_version = "2.20.0" {{#hasFormParamsInSpec}} httpmime_version = "4.5.13" {{/hasFormParamsInSpec}} @@ -75,12 +75,17 @@ dependencies { implementation "io.swagger.core.v3:swagger-annotations:$swagger_annotations_version" {{/swagger2AnnotationLibrary}} implementation "com.google.code.findbugs:jsr305:3.0.2" - implementation "com.fasterxml.jackson.core:jackson-core:$jackson_version" - implementation "com.fasterxml.jackson.core:jackson-annotations:$jackson_version" - implementation "com.fasterxml.jackson.core:jackson-databind:$jackson_version" - implementation "com.fasterxml.jackson.datatype:jackson-datatype-jsr310:$jackson_version" - implementation "org.openapitools:jackson-databind-nullable:0.2.6" - implementation platform("io.opentelemetry:opentelemetry-bom:1.53.0") + + // ---- Jackson ---- + implementation platform("com.fasterxml.jackson:jackson-bom:$jackson_version") + implementation "com.fasterxml.jackson.core:jackson-core" + implementation "com.fasterxml.jackson.core:jackson-annotations" + implementation "com.fasterxml.jackson.core:jackson-databind" + implementation "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" + implementation "org.openapitools:jackson-databind-nullable:0.2.7" + + // ---- OpenTelemetry ---- + implementation platform("io.opentelemetry:opentelemetry-bom:1.54.1") implementation "io.opentelemetry:opentelemetry-api" {{#hasFormParamsInSpec}} implementation "org.apache.httpcomponents:httpmime:$httpmime_version" @@ -92,9 +97,9 @@ testing { test { useJUnitJupiter() dependencies { - implementation 'org.assertj:assertj-core:3.27.4' - implementation 'org.mockito:mockito-core:5.18.0' - implementation 'org.junit.jupiter:junit-jupiter:5.13.4' + implementation 'org.assertj:assertj-core:3.27.6' + implementation 'org.mockito:mockito-core:5.20.0' + implementation 'org.junit.jupiter:junit-jupiter:5.14.0' implementation 'org.wiremock:wiremock:3.13.1' runtimeOnly 'org.junit.platform:junit-platform-launcher' @@ -122,8 +127,11 @@ testing { useJUnitJupiter() dependencies { - implementation "com.fasterxml.jackson.core:jackson-core:$jackson_version" - implementation "com.fasterxml.jackson.core:jackson-databind:$jackson_version" + // --- Jackson --- + implementation platform("com.fasterxml.jackson:jackson-bom:$jackson_version") + implementation "com.fasterxml.jackson.core:jackson-core" + implementation "com.fasterxml.jackson.core:jackson-databind" + implementation "org.testcontainers:junit-jupiter:1.21.3" implementation "org.testcontainers:openfga:1.21.3" implementation project() From 3896026be75f9cedf86c8b9c9019214f65016851 Mon Sep 17 00:00:00 2001 From: Ewan Harris Date: Tue, 7 Oct 2025 12:02:45 +0100 Subject: [PATCH 3/3] chore: cleanup to make example generate nicely Co-authored-by: Jim Anderson --- config/clients/java/config.overrides.json | 26 ++++------ .../clients/java/template/example/README.md | 50 +++---------------- .../example/example1/build.gradle.mustache | 8 +-- .../gradle/wrapper/gradle-wrapper.properties | 2 +- .../dev/openfga/sdk/example/Example1.java | 8 +-- .../clients/java/template/gitignore.mustache | 6 +++ 6 files changed, 32 insertions(+), 68 deletions(-) diff --git a/config/clients/java/config.overrides.json b/config/clients/java/config.overrides.json index 7c487ad4..85cbd21a 100644 --- a/config/clients/java/config.overrides.json +++ b/config/clients/java/config.overrides.json @@ -565,52 +565,48 @@ "destinationFilename": "docs/OpenTelemetry.md", "templateType": "SupportingFiles" }, - "example/Makefile": { - "destinationFilename": "example/Makefile", - "templateType": "SupportingFiles" - }, "example/README.md": { - "destinationFilename": "example/README.md", + "destinationFilename": "examples/README.md", "templateType": "SupportingFiles" }, "example/example1/README.md": { - "destinationFilename": "example/example1/README.md", + "destinationFilename": "examples/basic-examples/README.md", "templateType": "SupportingFiles" }, "example/example1/gradle/wrapper/gradle-wrapper.jar": { - "destinationFilename": "example/example1/gradle/wrapper/gradle-wrapper.jar", + "destinationFilename": "examples/basic-examples/gradle/wrapper/gradle-wrapper.jar", "templateType": "SupportingFiles" }, "example/example1/gradle/wrapper/gradle-wrapper.properties": { - "destinationFilename": "example/example1/gradle/wrapper/gradle-wrapper.properties", + "destinationFilename": "examples/basic-examples/gradle/wrapper/gradle-wrapper.properties", "templateType": "SupportingFiles" }, "example/example1/gradlew": { - "destinationFilename": "example/example1/gradlew", + "destinationFilename": "examples/basic-examples/gradlew", "templateType": "SupportingFiles" }, "example/example1/build.gradle.mustache": { - "destinationFilename": "example/example1/build.gradle", + "destinationFilename": "examples/basic-examples/build.gradle", "templateType": "SupportingFiles" }, "example/example1/gradle.properties": { - "destinationFilename": "example/example1/gradle.properties", + "destinationFilename": "examples/basic-examples/gradle.properties", "templateType": "SupportingFiles" }, "example/example1/settings.gradle": { - "destinationFilename": "example/example1/settings.gradle", + "destinationFilename": "examples/basic-examples/settings.gradle", "templateType": "SupportingFiles" }, "example/example1/src/main/resources/example1-auth-model.json": { - "destinationFilename": "example/example1/src/main/resources/example1-auth-model.json", + "destinationFilename": "examples/basic-examples/src/main/resources/example1-auth-model.json", "templateType": "SupportingFiles" }, "example/example1/src/main/java/dev/openfga/sdk/example/Example1.java": { - "destinationFilename": "example/example1/src/main/java/dev/openfga/sdk/example/Example1.java", + "destinationFilename": "examples/basic-examples/src/main/java/dev/openfga/sdk/example/Example1.java", "templateType": "SupportingFiles" }, "example/example1/src/main/kotlin/dev/openfga/sdk/example/KotlinExample1.kt": { - "destinationFilename": "example/example1/src/main/kotlin/dev/openfga/sdk/example/KotlinExample1.kt", + "destinationFilename": "examples/basic-examples/src/main/kotlin/dev/openfga/sdk/example/KotlinExample1.kt", "templateType": "SupportingFiles" }, "ExampleTest.java.mustache": { diff --git a/config/clients/java/template/example/README.md b/config/clients/java/template/example/README.md index 225a5eec..47238df4 100644 --- a/config/clients/java/template/example/README.md +++ b/config/clients/java/template/example/README.md @@ -1,49 +1,11 @@ ## Examples of using the OpenFGA Java SDK -A set of Examples on how to call the OpenFGA Java SDK +A collection of examples demonstrating how to use the OpenFGA Java SDK in different scenarios. -### Examples -Example 1: -A bare-bones example. It creates a store, and runs a set of calls against it including creating a model, writing tuples and checking for access. -This example is implemented in both Java and Kotlin. +### Available Examples +#### Basic Examples (`basic-examples/`) +A simple example that creates a store, runs a set of calls against it including creating a model, writing tuples and checking for access. This example is implemented in both Java and Kotlin. -### Running the Examples - -Prerequisites: -- `docker` -- `make` -- A Java Runtime Environment (JRE) - -#### Run using a published SDK - -Steps -1. Clone/Copy the example folder -2. Run `make` to build the project -3. If you have an OpenFGA server running, you can use it, otherwise run `make run-openfga` to spin up an instance (you'll need to switch to a different terminal after - don't forget to close it when done) -4. Run `make run` to run the example - -#### Run using a local unpublished SDK build - -Steps -1. Build the SDK -2. In the Example project file (e.g. `build.gradle`), comment out the part that specifies the remote SDK, e.g. -```groovy -dependencies { - implementation("dev.openfga:openfga-sdk:0.4.+") - - // ...etc -} -``` -and replace it with one pointing to the local gradle project, e.g. -```groovy -dependencies { - // implementation("dev.openfga:openfga-sdk:0.4.+") - implementation project(path: ':') - - // ...etc -} -``` -3. Run `make` to build the project -4. If you have an OpenFGA server running, you can use it, otherwise run `make run-openfga` to spin up an instance (you'll need to switch to a different terminal after - don't forget to close it when done) -5. Run `make run` to run the example +#### OpenTelemetry Examples +- `opentelemetry/` - Demonstrates OpenTelemetry integration both via manual code configuration, as well as no-code instrumentation using the OpenTelemetry java agent diff --git a/config/clients/java/template/example/example1/build.gradle.mustache b/config/clients/java/template/example/example1/build.gradle.mustache index 8d3b5a63..e1519498 100644 --- a/config/clients/java/template/example/example1/build.gradle.mustache +++ b/config/clients/java/template/example/example1/build.gradle.mustache @@ -1,7 +1,7 @@ plugins { id 'application' - id 'com.diffplug.spotless' version '7.2.1' - id 'org.jetbrains.kotlin.jvm' version '2.2.10' + id 'com.diffplug.spotless' version '8.0.0' + id 'org.jetbrains.kotlin.jvm' version '2.2.20' } application { @@ -19,7 +19,7 @@ repositories { } ext { - jacksonVersion = "2.19.2" + jacksonVersion = "2.20.0" } dependencies { @@ -30,7 +30,7 @@ dependencies { implementation("com.fasterxml.jackson.core:jackson-annotations:$jacksonVersion") implementation("com.fasterxml.jackson.core:jackson-databind:$jacksonVersion") implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:$jacksonVersion") - implementation("org.openapitools:jackson-databind-nullable:0.2.6") + implementation("org.openapitools:jackson-databind-nullable:0.2.7") // Kotlin implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8" diff --git a/config/clients/java/template/example/example1/gradle/wrapper/gradle-wrapper.properties b/config/clients/java/template/example/example1/gradle/wrapper/gradle-wrapper.properties index 42defcc9..9b0a13f0 100644 --- a/config/clients/java/template/example/example1/gradle/wrapper/gradle-wrapper.properties +++ b/config/clients/java/template/example/example1/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-all.zip networkTimeout=10000 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/config/clients/java/template/example/example1/src/main/java/dev/openfga/sdk/example/Example1.java b/config/clients/java/template/example/example1/src/main/java/dev/openfga/sdk/example/Example1.java index 3961f84f..97d0cfe0 100644 --- a/config/clients/java/template/example/example1/src/main/java/dev/openfga/sdk/example/Example1.java +++ b/config/clients/java/template/example/example1/src/main/java/dev/openfga/sdk/example/Example1.java @@ -28,8 +28,7 @@ public void run(String apiUrl) throws Exception { var configuration = new ClientConfiguration() .apiUrl(apiUrl) // required, e.g. https://api.fga.example .storeId(System.getenv("FGA_STORE_ID")) // not needed when calling `CreateStore` or `ListStores` - .authorizationModelId( - System.getenv("FGA_MODEL_ID")) // Optional, can be overridden per request + .authorizationModelId(System.getenv("FGA_MODEL_ID")) // Optional, can be overridden per request .credentials(credentials); var fgaClient = new OpenFgaClient(configuration); @@ -114,8 +113,9 @@ public void run(String apiUrl) throws Exception { new ClientTupleKey() .user("user:anne") .relation("owner") - ._object("document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a") // different relation - )), + ._object("document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a") // different + // relation + )), new ClientWriteOptions() .disableTransactions(true) .authorizationModelId(authorizationModel.getAuthorizationModelId())) diff --git a/config/clients/java/template/gitignore.mustache b/config/clients/java/template/gitignore.mustache index c3784f2d..7da40f54 100644 --- a/config/clients/java/template/gitignore.mustache +++ b/config/clients/java/template/gitignore.mustache @@ -30,3 +30,9 @@ VERSION.txt # VSCode IDE /.vscode + +# env files +.env + +# mac +.DS_Store \ No newline at end of file