Skip to content

Commit 02b1b41

Browse files
author
Daniel Yeam
committed
Add comprehensive tests for per-request custom HTTP headers functionality + fix SDK bug
Tests: - Add core functionality tests for all API methods (check, write, read, etc.) - Add edge case tests for invalid inputs and boundary conditions - Add synchronous client compatibility tests - Add summary test demonstrating real-world usage patterns - Covers both async and sync clients with 1,270+ lines of test coverage SDK Bug Fix: - Fix header merging logic in ApiClient to allow custom headers to override default headers - Previously default headers would overwrite custom headers due to incorrect merge order - Now custom headers properly take precedence over defaults (except system headers like Accept/Content-Type) Resolves #217
1 parent 89a39d1 commit 02b1b41

File tree

6 files changed

+1283
-2
lines changed

6 files changed

+1283
-2
lines changed

openfga_sdk/api_client.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,10 @@ async def __call_api(
179179

180180
# header parameters
181181
header_params = header_params or {}
182-
header_params.update(self.default_headers)
182+
# Merge headers with custom headers taking precedence over defaults
183+
merged_headers = self.default_headers.copy()
184+
merged_headers.update(header_params)
185+
header_params = merged_headers
183186
if self.cookie:
184187
header_params["Cookie"] = self.cookie
185188
if header_params:

openfga_sdk/sync/api_client.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,10 @@ def __call_api(
178178

179179
# header parameters
180180
header_params = header_params or {}
181-
header_params.update(self.default_headers)
181+
# Merge headers with custom headers taking precedence over defaults
182+
merged_headers = self.default_headers.copy()
183+
merged_headers.update(header_params)
184+
header_params = merged_headers
182185
if self.cookie:
183186
header_params["Cookie"] = self.cookie
184187
if header_params:
Lines changed: 367 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,367 @@
1+
"""
2+
Test edge cases and error scenarios for per-request custom HTTP headers functionality
3+
4+
This module tests edge cases, invalid inputs, and error scenarios for the
5+
per-request headers feature to ensure robust handling.
6+
"""
7+
8+
import json
9+
from unittest import IsolatedAsyncioTestCase
10+
from unittest.mock import ANY, patch
11+
12+
import urllib3
13+
14+
from openfga_sdk import rest
15+
from openfga_sdk.client import ClientConfiguration
16+
from openfga_sdk.client.client import OpenFgaClient, options_to_kwargs, set_heading_if_not_set
17+
from openfga_sdk.client.models.check_request import ClientCheckRequest
18+
19+
20+
store_id = "01YCP46JKYM8FJCQ37NMBYHE5X"
21+
auth_model_id = "01YCP46JKYM8FJCQ37NMBYHE6X"
22+
request_id = "x1y2z3"
23+
24+
25+
def http_mock_response(body, status):
26+
headers = urllib3.response.HTTPHeaderDict(
27+
{"content-type": "application/json", "Fga-Request-Id": request_id}
28+
)
29+
return urllib3.HTTPResponse(
30+
body.encode("utf-8"), headers, status, preload_content=False
31+
)
32+
33+
34+
def mock_response(body, status):
35+
obj = http_mock_response(body, status)
36+
return rest.RESTResponse(obj, obj.data)
37+
38+
39+
class TestPerRequestHeadersEdgeCases(IsolatedAsyncioTestCase):
40+
"""Test edge cases and error scenarios for per-request headers"""
41+
42+
def setUp(self):
43+
self.configuration = ClientConfiguration(
44+
api_url="http://api.fga.example",
45+
store_id=store_id,
46+
authorization_model_id=auth_model_id,
47+
)
48+
49+
def tearDown(self):
50+
pass
51+
52+
def test_options_to_kwargs_with_headers(self):
53+
"""Test options_to_kwargs function properly handles headers"""
54+
options = {
55+
"headers": {
56+
"x-test-header": "test-value",
57+
"x-another": "another-value"
58+
},
59+
"authorization_model_id": "test-model",
60+
"page_size": 25
61+
}
62+
63+
result = options_to_kwargs(options)
64+
65+
# Check that headers are converted to headers
66+
self.assertIn("headers", result)
67+
self.assertEqual(result["headers"]["x-test-header"], "test-value")
68+
self.assertEqual(result["headers"]["x-another"], "another-value")
69+
70+
# Check that other options are preserved
71+
self.assertEqual(result.get("page_size"), 25)
72+
73+
def test_options_to_kwargs_without_headers(self):
74+
"""Test options_to_kwargs function works without headers"""
75+
options = {
76+
"authorization_model_id": "test-model",
77+
"page_size": 25
78+
}
79+
80+
result = options_to_kwargs(options)
81+
82+
# Check that headers is not present when no headers option
83+
self.assertNotIn("headers", result)
84+
85+
# Check that other options are preserved
86+
self.assertEqual(result.get("page_size"), 25)
87+
88+
def test_options_to_kwargs_with_none(self):
89+
"""Test options_to_kwargs function handles None input"""
90+
result = options_to_kwargs(None)
91+
92+
# Should return empty dict
93+
self.assertEqual(result, {})
94+
95+
def test_options_to_kwargs_with_empty_dict(self):
96+
"""Test options_to_kwargs function handles empty dict input"""
97+
result = options_to_kwargs({})
98+
99+
# Should return empty dict
100+
self.assertEqual(result, {})
101+
102+
def test_set_heading_if_not_set_with_existing_headers(self):
103+
"""Test set_heading_if_not_set function with existing headers"""
104+
options = {
105+
"headers": {
106+
"x-existing": "existing-value"
107+
}
108+
}
109+
110+
result = set_heading_if_not_set(options, "x-new-header", "new-value")
111+
112+
# Check that new header was added
113+
self.assertEqual(result["headers"]["x-new-header"], "new-value")
114+
# Check that existing header is preserved
115+
self.assertEqual(result["headers"]["x-existing"], "existing-value")
116+
117+
def test_set_heading_if_not_set_without_headers(self):
118+
"""Test set_heading_if_not_set function when headers dict doesn't exist"""
119+
options = {
120+
"other_option": "value"
121+
}
122+
123+
result = set_heading_if_not_set(options, "x-new-header", "new-value")
124+
125+
# Check that headers dict was created and header was added
126+
self.assertIn("headers", result)
127+
self.assertEqual(result["headers"]["x-new-header"], "new-value")
128+
# Check that other options are preserved
129+
self.assertEqual(result["other_option"], "value")
130+
131+
def test_set_heading_if_not_set_with_none_options(self):
132+
"""Test set_heading_if_not_set function with None options"""
133+
result = set_heading_if_not_set(None, "x-new-header", "new-value")
134+
135+
# Check that options dict was created with headers
136+
self.assertIn("headers", result)
137+
self.assertEqual(result["headers"]["x-new-header"], "new-value")
138+
139+
def test_set_heading_if_not_set_header_already_exists(self):
140+
"""Test set_heading_if_not_set function when header already exists"""
141+
options = {
142+
"headers": {
143+
"x-existing": "original-value"
144+
}
145+
}
146+
147+
result = set_heading_if_not_set(options, "x-existing", "new-value")
148+
149+
# Check that original value is preserved (not overwritten)
150+
self.assertEqual(result["headers"]["x-existing"], "original-value")
151+
152+
def test_set_heading_if_not_set_with_invalidheaders_type(self):
153+
"""Test set_heading_if_not_set function with invalid headers type"""
154+
options = {
155+
"headers": "not-a-dict" # Invalid type
156+
}
157+
158+
result = set_heading_if_not_set(options, "x-new-header", "new-value")
159+
160+
# Function should create new headers dict, replacing the invalid one
161+
self.assertIsInstance(result["headers"], dict)
162+
self.assertEqual(result["headers"]["x-new-header"], "new-value")
163+
164+
@patch.object(rest.RESTClientObject, "request")
165+
async def test_headers_with_invalid_type_in_options(self, mock_request):
166+
"""Test that invalid headers type in options is handled gracefully"""
167+
response_body = '{"allowed": true}'
168+
mock_request.return_value = mock_response(response_body, 200)
169+
170+
# This should be handled gracefully - converted to dict or ignored
171+
options_with_invalidheaders = {
172+
"headers": "not-a-dict"
173+
}
174+
175+
async with OpenFgaClient(self.configuration) as fga_client:
176+
body = ClientCheckRequest(
177+
user="user:test-user",
178+
relation="viewer",
179+
object="document:test-doc",
180+
)
181+
182+
# This should not raise an exception
183+
await fga_client.check(body, options_with_invalidheaders)
184+
185+
# Verify the request was made
186+
mock_request.assert_called_once()
187+
188+
@patch.object(rest.RESTClientObject, "request")
189+
async def test_large_number_of_headers(self, mock_request):
190+
"""Test that a large number of headers is handled correctly"""
191+
response_body = '{"allowed": true}'
192+
mock_request.return_value = mock_response(response_body, 200)
193+
194+
# Create a large number of headers
195+
largeheaders = {f"x-header-{i}": f"value-{i}" for i in range(100)}
196+
197+
async with OpenFgaClient(self.configuration) as fga_client:
198+
options = {
199+
"headers": largeheaders
200+
}
201+
202+
body = ClientCheckRequest(
203+
user="user:test-user",
204+
relation="viewer",
205+
object="document:test-doc",
206+
)
207+
208+
await fga_client.check(body, options)
209+
210+
# Verify the request was made with all headers
211+
mock_request.assert_called_once()
212+
call_args = mock_request.call_args
213+
headers = call_args.kwargs.get("headers", {})
214+
215+
# Check that all headers were included
216+
self.assertEqual(len(headers), 100)
217+
for i in range(100):
218+
self.assertEqual(headers[f"x-header-{i}"], f"value-{i}")
219+
220+
@patch.object(rest.RESTClientObject, "request")
221+
async def test_unicode_headers(self, mock_request):
222+
"""Test that unicode characters in headers are handled correctly"""
223+
response_body = '{"allowed": true}'
224+
mock_request.return_value = mock_response(response_body, 200)
225+
226+
unicode_headers = {
227+
"x-unicode-header": "测试值", # Chinese characters
228+
"x-emoji-header": "🚀🔐", # Emojis
229+
"x-accented-header": "café-résumé", # Accented characters
230+
}
231+
232+
async with OpenFgaClient(self.configuration) as fga_client:
233+
options = {
234+
"headers": unicode_headers
235+
}
236+
237+
body = ClientCheckRequest(
238+
user="user:test-user",
239+
relation="viewer",
240+
object="document:test-doc",
241+
)
242+
243+
await fga_client.check(body, options)
244+
245+
# Verify the request was made with unicode headers
246+
mock_request.assert_called_once()
247+
call_args = mock_request.call_args
248+
headers = call_args.kwargs.get("headers", {})
249+
250+
# Check that unicode headers were included
251+
self.assertEqual(headers["x-unicode-header"], "测试值")
252+
self.assertEqual(headers["x-emoji-header"], "🚀🔐")
253+
self.assertEqual(headers["x-accented-header"], "café-résumé")
254+
255+
@patch.object(rest.RESTClientObject, "request")
256+
async def test_long_header_values(self, mock_request):
257+
"""Test that very long header values are handled correctly"""
258+
response_body = '{"allowed": true}'
259+
mock_request.return_value = mock_response(response_body, 200)
260+
261+
# Create a very long header value
262+
long_value = "x" * 10000 # 10KB header value
263+
264+
longheaders = {
265+
"x-long-header": long_value,
266+
"x-normal-header": "normal-value"
267+
}
268+
269+
async with OpenFgaClient(self.configuration) as fga_client:
270+
options = {
271+
"headers": longheaders
272+
}
273+
274+
body = ClientCheckRequest(
275+
user="user:test-user",
276+
relation="viewer",
277+
object="document:test-doc",
278+
)
279+
280+
await fga_client.check(body, options)
281+
282+
# Verify the request was made with long headers
283+
mock_request.assert_called_once()
284+
call_args = mock_request.call_args
285+
headers = call_args.kwargs.get("headers", {})
286+
287+
# Check that long header was included
288+
self.assertEqual(headers["x-long-header"], long_value)
289+
self.assertEqual(headers["x-normal-header"], "normal-value")
290+
291+
@patch.object(rest.RESTClientObject, "request")
292+
async def test_header_case_sensitivity(self, mock_request):
293+
"""Test that header case is preserved"""
294+
response_body = '{"allowed": true}'
295+
mock_request.return_value = mock_response(response_body, 200)
296+
297+
case_sensitiveheaders = {
298+
"X-Upper-Case": "upper-value",
299+
"x-lower-case": "lower-value",
300+
"X-Mixed-Case": "mixed-value",
301+
"x-WEIRD-cAsE": "weird-value"
302+
}
303+
304+
async with OpenFgaClient(self.configuration) as fga_client:
305+
options = {
306+
"headers": case_sensitiveheaders
307+
}
308+
309+
body = ClientCheckRequest(
310+
user="user:test-user",
311+
relation="viewer",
312+
object="document:test-doc",
313+
)
314+
315+
await fga_client.check(body, options)
316+
317+
# Verify the request was made with case-preserved headers
318+
mock_request.assert_called_once()
319+
call_args = mock_request.call_args
320+
headers = call_args.kwargs.get("headers", {})
321+
322+
# Check that header case was preserved
323+
self.assertEqual(headers["X-Upper-Case"], "upper-value")
324+
self.assertEqual(headers["x-lower-case"], "lower-value")
325+
self.assertEqual(headers["X-Mixed-Case"], "mixed-value")
326+
self.assertEqual(headers["x-WEIRD-cAsE"], "weird-value")
327+
328+
@patch.object(rest.RESTClientObject, "request")
329+
async def test_header_overrides_default_headers(self, mock_request):
330+
"""Test that custom headers can override overrideable default headers"""
331+
response_body = '{"allowed": true}'
332+
mock_request.return_value = mock_response(response_body, 200)
333+
334+
# Test with headers that can override defaults (User-Agent)
335+
# Note: Accept and Content-Type are set by the API method and cannot be overridden
336+
override_headers = {
337+
"User-Agent": "custom-user-agent",
338+
"x-custom-header": "custom-value",
339+
"Authorization": "Bearer custom-token",
340+
}
341+
342+
async with OpenFgaClient(self.configuration) as fga_client:
343+
options = {
344+
"headers": override_headers
345+
}
346+
347+
body = ClientCheckRequest(
348+
user="user:test-user",
349+
relation="viewer",
350+
object="document:test-doc",
351+
)
352+
353+
await fga_client.check(body, options)
354+
355+
# Verify the request was made
356+
mock_request.assert_called_once()
357+
call_args = mock_request.call_args
358+
headers = call_args.kwargs.get("headers", {})
359+
360+
# Check that overrideable custom headers work
361+
self.assertEqual(headers["User-Agent"], "custom-user-agent")
362+
self.assertEqual(headers["x-custom-header"], "custom-value")
363+
self.assertEqual(headers["Authorization"], "Bearer custom-token")
364+
365+
# System headers are still set by the API method
366+
self.assertEqual(headers["Accept"], "application/json")
367+
self.assertTrue("Content-Type" in headers)

0 commit comments

Comments
 (0)