Skip to content

Commit 65d874b

Browse files
authored
fix: preserve response headers for txn writes (#254)
* feat: add changelog * fix: client write response headers fix * feat: fmt and whitespace * fix: remove changelog change * fix: changelog update
1 parent d6d5908 commit 65d874b

File tree

4 files changed

+339
-3
lines changed

4 files changed

+339
-3
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Changelog
22

33
## [Unreleased](https://github.com/openfga/java-sdk/compare/v0.9.2...HEAD)
4+
- fix: preserve response headers in transaction write operations (#254)
45

56
## v0.9.2
67

src/main/java/dev/openfga/sdk/api/client/OpenFgaClient.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -507,7 +507,7 @@ private CompletableFuture<ClientWriteResponse> writeTransactions(
507507
.collect(Collectors.toList())
508508
: new ArrayList<>();
509509

510-
return new ClientWriteResponse(writeResponses, deleteResponses);
510+
return new ClientWriteResponse(apiResponse, writeResponses, deleteResponses);
511511
});
512512
}
513513

@@ -710,7 +710,7 @@ public CompletableFuture<ClientWriteResponse> writeTuples(
710710
List<ClientWriteSingleResponse> writeResponses = tupleKeys.stream()
711711
.map(tuple -> new ClientWriteSingleResponse(tuple.asTupleKey(), ClientWriteStatus.SUCCESS))
712712
.collect(Collectors.toList());
713-
return new ClientWriteResponse(writeResponses, new ArrayList<>());
713+
return new ClientWriteResponse(apiResponse, writeResponses, new ArrayList<>());
714714
});
715715
}
716716

@@ -756,7 +756,7 @@ public CompletableFuture<ClientWriteResponse> deleteTuples(
756756
._object(tuple.getObject()),
757757
ClientWriteStatus.SUCCESS))
758758
.collect(Collectors.toList());
759-
return new ClientWriteResponse(new ArrayList<>(), deleteResponses);
759+
return new ClientWriteResponse(apiResponse, new ArrayList<>(), deleteResponses);
760760
});
761761
}
762762

src/main/java/dev/openfga/sdk/api/client/model/ClientWriteResponse.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,17 @@ public ClientWriteResponse(List<ClientWriteSingleResponse> writes, List<ClientWr
2828
this.deletes = deletes != null ? deletes : Collections.emptyList();
2929
}
3030

31+
public ClientWriteResponse(
32+
ApiResponse<Object> apiResponse,
33+
List<ClientWriteSingleResponse> writes,
34+
List<ClientWriteSingleResponse> deletes) {
35+
this.statusCode = apiResponse.getStatusCode();
36+
this.headers = apiResponse.getHeaders();
37+
this.rawResponse = apiResponse.getRawResponse();
38+
this.writes = writes != null ? writes : Collections.emptyList();
39+
this.deletes = deletes != null ? deletes : Collections.emptyList();
40+
}
41+
3142
public int getStatusCode() {
3243
return statusCode;
3344
}
Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
1+
package dev.openfga.sdk.api.client;
2+
3+
import static com.github.tomakehurst.wiremock.client.WireMock.*;
4+
import static org.junit.jupiter.api.Assertions.*;
5+
6+
import com.github.tomakehurst.wiremock.WireMockServer;
7+
import com.github.tomakehurst.wiremock.core.WireMockConfiguration;
8+
import dev.openfga.sdk.api.client.model.*;
9+
import dev.openfga.sdk.api.configuration.ClientConfiguration;
10+
import dev.openfga.sdk.api.configuration.ClientWriteOptions;
11+
import java.util.List;
12+
import org.junit.jupiter.api.AfterEach;
13+
import org.junit.jupiter.api.BeforeEach;
14+
import org.junit.jupiter.api.Test;
15+
16+
/**
17+
* Tests for verifying that response headers are properly returned in ClientWriteResponse.
18+
* This test suite specifically addresses the issue where headers were not being returned
19+
* for transaction mode writes.
20+
*/
21+
public class OpenFgaClientWriteResponseHeadersTest {
22+
private static final String DEFAULT_STORE_ID = "01YCP46JKYM8FJCQ37NMBYHE5X";
23+
private static final String DEFAULT_AUTH_MODEL_ID = "01G5JAVJ41T49E9TT3SKVS7X1J";
24+
private static final String DEFAULT_USER = "user:anne";
25+
private static final String DEFAULT_RELATION = "reader";
26+
private static final String DEFAULT_OBJECT = "document:budget";
27+
28+
private WireMockServer wireMockServer;
29+
private OpenFgaClient fgaClient;
30+
31+
@BeforeEach
32+
void setUp() throws Exception {
33+
wireMockServer =
34+
new WireMockServer(WireMockConfiguration.wireMockConfig().dynamicPort());
35+
wireMockServer.start();
36+
37+
ClientConfiguration configuration = new ClientConfiguration()
38+
.apiUrl("http://localhost:" + wireMockServer.port())
39+
.storeId(DEFAULT_STORE_ID)
40+
.authorizationModelId(DEFAULT_AUTH_MODEL_ID);
41+
42+
fgaClient = new OpenFgaClient(configuration);
43+
}
44+
45+
@AfterEach
46+
void tearDown() {
47+
if (wireMockServer != null) {
48+
wireMockServer.stop();
49+
}
50+
}
51+
52+
/**
53+
* Test that transaction mode writes return response headers.
54+
* This is the primary test case for the fix.
55+
*/
56+
@Test
57+
void writeTransactionMode_shouldReturnResponseHeaders() throws Exception {
58+
// Given
59+
String writePath = String.format("/stores/%s/write", DEFAULT_STORE_ID);
60+
wireMockServer.stubFor(post(urlEqualTo(writePath))
61+
.willReturn(aResponse()
62+
.withStatus(200)
63+
.withHeader("Content-Type", "application/json")
64+
.withHeader("X-Custom-Header", "custom-value")
65+
.withHeader("X-Request-Id", "req-123")
66+
.withBody("{}")));
67+
68+
ClientWriteRequest request = new ClientWriteRequest()
69+
.writes(List.of(new ClientTupleKey()
70+
.user(DEFAULT_USER)
71+
.relation(DEFAULT_RELATION)
72+
._object(DEFAULT_OBJECT)));
73+
74+
// When
75+
ClientWriteResponse response = fgaClient.write(request).get();
76+
77+
// Then
78+
assertNotNull(response.getHeaders(), "Headers should not be null");
79+
assertFalse(response.getHeaders().isEmpty(), "Headers should not be empty");
80+
81+
// Verify specific headers are present (case-insensitive)
82+
assertTrue(response.getHeaders().containsKey("content-type"), "Should contain Content-Type header");
83+
assertTrue(response.getHeaders().containsKey("x-custom-header"), "Should contain X-Custom-Header");
84+
assertTrue(response.getHeaders().containsKey("x-request-id"), "Should contain X-Request-Id");
85+
86+
// Verify header values
87+
assertEquals(List.of("application/json"), response.getHeaders().get("content-type"));
88+
assertEquals(List.of("custom-value"), response.getHeaders().get("x-custom-header"));
89+
assertEquals(List.of("req-123"), response.getHeaders().get("x-request-id"));
90+
}
91+
92+
/**
93+
* Test that transaction mode writes with deletes also return response headers.
94+
*/
95+
@Test
96+
void writeTransactionModeWithDeletes_shouldReturnResponseHeaders() throws Exception {
97+
// Given
98+
String writePath = String.format("/stores/%s/write", DEFAULT_STORE_ID);
99+
wireMockServer.stubFor(post(urlEqualTo(writePath))
100+
.willReturn(aResponse()
101+
.withStatus(200)
102+
.withHeader("X-Response-Header", "response-value")
103+
.withBody("{}")));
104+
105+
ClientWriteRequest request = new ClientWriteRequest()
106+
.deletes(List.of(new ClientTupleKeyWithoutCondition()
107+
.user(DEFAULT_USER)
108+
.relation(DEFAULT_RELATION)
109+
._object(DEFAULT_OBJECT)));
110+
111+
// When
112+
ClientWriteResponse response = fgaClient.write(request).get();
113+
114+
// Then
115+
assertNotNull(response.getHeaders());
116+
assertFalse(response.getHeaders().isEmpty());
117+
assertTrue(response.getHeaders().containsKey("x-response-header"));
118+
assertEquals(List.of("response-value"), response.getHeaders().get("x-response-header"));
119+
}
120+
121+
/**
122+
* Test that transaction mode writes with both writes and deletes return response headers.
123+
*/
124+
@Test
125+
void writeTransactionModeWithWritesAndDeletes_shouldReturnResponseHeaders() throws Exception {
126+
// Given
127+
String writePath = String.format("/stores/%s/write", DEFAULT_STORE_ID);
128+
wireMockServer.stubFor(post(urlEqualTo(writePath))
129+
.willReturn(aResponse()
130+
.withStatus(200)
131+
.withHeader("X-Combined-Header", "combined-value")
132+
.withBody("{}")));
133+
134+
ClientWriteRequest request = new ClientWriteRequest()
135+
.writes(List.of(new ClientTupleKey()
136+
.user(DEFAULT_USER)
137+
.relation(DEFAULT_RELATION)
138+
._object(DEFAULT_OBJECT)))
139+
.deletes(List.of(new ClientTupleKeyWithoutCondition()
140+
.user("user:bob")
141+
.relation(DEFAULT_RELATION)
142+
._object(DEFAULT_OBJECT)));
143+
144+
// When
145+
ClientWriteResponse response = fgaClient.write(request).get();
146+
147+
// Then
148+
assertNotNull(response.getHeaders());
149+
assertFalse(response.getHeaders().isEmpty());
150+
assertTrue(response.getHeaders().containsKey("x-combined-header"));
151+
assertEquals(List.of("combined-value"), response.getHeaders().get("x-combined-header"));
152+
}
153+
154+
/**
155+
* Test that non-transaction mode writes return empty headers.
156+
* Non-transaction mode aggregates multiple API responses, so it's expected
157+
* that headers are empty (as there's no single response to get headers from).
158+
*/
159+
@Test
160+
void writeNonTransactionMode_shouldReturnEmptyHeaders() throws Exception {
161+
// Given
162+
String writePath = String.format("/stores/%s/write", DEFAULT_STORE_ID);
163+
wireMockServer.stubFor(post(urlEqualTo(writePath))
164+
.willReturn(aResponse()
165+
.withStatus(200)
166+
.withHeader("X-Response-Header", "should-not-appear")
167+
.withBody("{}")));
168+
169+
ClientWriteRequest request = new ClientWriteRequest()
170+
.writes(List.of(new ClientTupleKey()
171+
.user(DEFAULT_USER)
172+
.relation(DEFAULT_RELATION)
173+
._object(DEFAULT_OBJECT)));
174+
175+
ClientWriteOptions options = new ClientWriteOptions().disableTransactions(true);
176+
177+
// When
178+
ClientWriteResponse response = fgaClient.write(request, options).get();
179+
180+
// Then
181+
assertNotNull(response.getHeaders());
182+
assertTrue(
183+
response.getHeaders().isEmpty(),
184+
"Non-transaction mode should return empty headers as it aggregates multiple responses");
185+
}
186+
187+
/**
188+
* Test writeTuples() helper method returns headers.
189+
*/
190+
@Test
191+
void writeTuples_shouldReturnResponseHeaders() throws Exception {
192+
// Given
193+
String writePath = String.format("/stores/%s/write", DEFAULT_STORE_ID);
194+
wireMockServer.stubFor(post(urlEqualTo(writePath))
195+
.willReturn(aResponse()
196+
.withStatus(200)
197+
.withHeader("X-Write-Tuples-Header", "write-tuples-value")
198+
.withBody("{}")));
199+
200+
List<ClientTupleKey> tuples = List.of(new ClientTupleKey()
201+
.user(DEFAULT_USER)
202+
.relation(DEFAULT_RELATION)
203+
._object(DEFAULT_OBJECT));
204+
205+
// When
206+
ClientWriteResponse response = fgaClient.writeTuples(tuples).get();
207+
208+
// Then
209+
assertNotNull(response.getHeaders());
210+
assertFalse(response.getHeaders().isEmpty());
211+
assertTrue(response.getHeaders().containsKey("x-write-tuples-header"));
212+
assertEquals(List.of("write-tuples-value"), response.getHeaders().get("x-write-tuples-header"));
213+
}
214+
215+
/**
216+
* Test deleteTuples() helper method returns headers.
217+
*/
218+
@Test
219+
void deleteTuples_shouldReturnResponseHeaders() throws Exception {
220+
// Given
221+
String writePath = String.format("/stores/%s/write", DEFAULT_STORE_ID);
222+
wireMockServer.stubFor(post(urlEqualTo(writePath))
223+
.willReturn(aResponse()
224+
.withStatus(200)
225+
.withHeader("X-Delete-Tuples-Header", "delete-tuples-value")
226+
.withBody("{}")));
227+
228+
List<ClientTupleKeyWithoutCondition> tuples = List.of(new ClientTupleKeyWithoutCondition()
229+
.user(DEFAULT_USER)
230+
.relation(DEFAULT_RELATION)
231+
._object(DEFAULT_OBJECT));
232+
233+
// When
234+
ClientWriteResponse response = fgaClient.deleteTuples(tuples).get();
235+
236+
// Then
237+
assertNotNull(response.getHeaders());
238+
assertFalse(response.getHeaders().isEmpty());
239+
assertTrue(response.getHeaders().containsKey("x-delete-tuples-header"));
240+
assertEquals(List.of("delete-tuples-value"), response.getHeaders().get("x-delete-tuples-header"));
241+
}
242+
243+
/**
244+
* Edge case: Test that empty headers from server are handled correctly.
245+
*/
246+
@Test
247+
void writeTransactionMode_withNoHeaders_shouldReturnEmptyMap() throws Exception {
248+
// Given
249+
String writePath = String.format("/stores/%s/write", DEFAULT_STORE_ID);
250+
wireMockServer.stubFor(post(urlEqualTo(writePath))
251+
.willReturn(aResponse().withStatus(200).withBody("{}")));
252+
253+
ClientWriteRequest request = new ClientWriteRequest()
254+
.writes(List.of(new ClientTupleKey()
255+
.user(DEFAULT_USER)
256+
.relation(DEFAULT_RELATION)
257+
._object(DEFAULT_OBJECT)));
258+
259+
// When
260+
ClientWriteResponse response = fgaClient.write(request).get();
261+
262+
// Then
263+
assertNotNull(response.getHeaders(), "Headers map should not be null even when empty");
264+
// Note: The response will likely still have some default HTTP headers
265+
// The important thing is that we don't crash and headers is not null
266+
}
267+
268+
/**
269+
* Test that status code is correctly preserved in transaction mode.
270+
*/
271+
@Test
272+
void writeTransactionMode_shouldPreserveStatusCode() throws Exception {
273+
// Given
274+
String writePath = String.format("/stores/%s/write", DEFAULT_STORE_ID);
275+
wireMockServer.stubFor(post(urlEqualTo(writePath))
276+
.willReturn(aResponse().withStatus(200).withBody("{}")));
277+
278+
ClientWriteRequest request = new ClientWriteRequest()
279+
.writes(List.of(new ClientTupleKey()
280+
.user(DEFAULT_USER)
281+
.relation(DEFAULT_RELATION)
282+
._object(DEFAULT_OBJECT)));
283+
284+
// When
285+
ClientWriteResponse response = fgaClient.write(request).get();
286+
287+
// Then
288+
assertEquals(200, response.getStatusCode());
289+
}
290+
291+
/**
292+
* Test backward compatibility: Verify that the response structure hasn't changed.
293+
*/
294+
@Test
295+
void writeTransactionMode_shouldMaintainBackwardCompatibility() throws Exception {
296+
// Given
297+
String writePath = String.format("/stores/%s/write", DEFAULT_STORE_ID);
298+
wireMockServer.stubFor(post(urlEqualTo(writePath))
299+
.willReturn(aResponse()
300+
.withStatus(200)
301+
.withHeader("X-Test-Header", "test-value")
302+
.withBody("{}")));
303+
304+
ClientWriteRequest request = new ClientWriteRequest()
305+
.writes(List.of(new ClientTupleKey()
306+
.user(DEFAULT_USER)
307+
.relation(DEFAULT_RELATION)
308+
._object(DEFAULT_OBJECT)));
309+
310+
// When
311+
ClientWriteResponse response = fgaClient.write(request).get();
312+
313+
// Then - All existing fields should still work
314+
assertEquals(200, response.getStatusCode());
315+
assertNotNull(response.getWrites());
316+
assertEquals(1, response.getWrites().size());
317+
assertNotNull(response.getDeletes());
318+
assertEquals(0, response.getDeletes().size());
319+
320+
// And headers should now be available
321+
assertNotNull(response.getHeaders());
322+
assertFalse(response.getHeaders().isEmpty());
323+
}
324+
}

0 commit comments

Comments
 (0)