Skip to content

Commit 3e8be76

Browse files
committed
fix: custom header precedence
1 parent 5bdc932 commit 3e8be76

File tree

10 files changed

+585
-147
lines changed

10 files changed

+585
-147
lines changed

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fopenfga%2Fpython-sdk.svg?type=shield)](https://app.fossa.com/projects/git%2Bgithub.com%2Fopenfga%2Fpython-sdk?ref=badge_shield)
77
[![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/openfga/python-sdk/badge)](https://securityscorecards.dev/viewer/?uri=github.com/openfga/python-sdk)
88
[![Join our community](https://img.shields.io/badge/slack-cncf_%23openfga-40abb8.svg?logo=slack)](https://openfga.dev/community)
9-
[![X](https://img.shields.io/twitter/follow/openfga?color=%23179CF0&logo=twitter&style=flat-square "@openfga on Twitter")](https://x.com/openfga)
9+
[![X](https://img.shields.io/twitter/follow/openfga?color=%23179CF0&logo=x&style=flat-square "@openfga on X")](https://x.com/openfga)
1010

1111
This is an autogenerated python SDK for OpenFGA. It provides a wrapper around the [OpenFGA API definition](https://openfga.dev/api).
1212

@@ -64,7 +64,7 @@ OpenFGA is designed to make it easy for application builders to model their perm
6464

6565
- [OpenFGA Documentation](https://openfga.dev/docs)
6666
- [OpenFGA API Documentation](https://openfga.dev/api/service)
67-
- [Twitter](https://twitter.com/openfga)
67+
- [X](https://x.com/openfga)
6868
- [OpenFGA Community](https://openfga.dev/community)
6969
- [Zanzibar Academy](https://zanzibar.academy)
7070
- [Google's Zanzibar Paper (2019)](https://research.google/pubs/pub48190/)

openfga_sdk/api_client.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,8 +178,7 @@ async def __call_api(
178178
start = float(time.time())
179179

180180
# header parameters
181-
header_params = header_params or {}
182-
header_params.update(self.default_headers)
181+
header_params = {**self.default_headers, **(header_params or {})}
183182
if self.cookie:
184183
header_params["Cookie"] = self.cookie
185184
if header_params:

openfga_sdk/client/client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ def set_heading_if_not_set(
106106
_options["headers"] = {}
107107

108108
if type(_options["headers"]) is dict:
109-
if type(_options["headers"].get(name)) not in [int, str]:
109+
if _options["headers"].get(name) is None:
110110
_options["headers"][name] = value
111111

112112
return _options

openfga_sdk/sync/api_client.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -177,8 +177,7 @@ def __call_api(
177177
start = float(time.time())
178178

179179
# header parameters
180-
header_params = header_params or {}
181-
header_params.update(self.default_headers)
180+
header_params = {**self.default_headers, **(header_params or {})}
182181
if self.cookie:
183182
header_params["Cookie"] = self.cookie
184183
if header_params:

openfga_sdk/sync/client/client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ def set_heading_if_not_set(
107107
_options["headers"] = {}
108108

109109
if type(_options["headers"]) is dict:
110-
if type(_options["headers"].get(name)) not in [int, str]:
110+
if _options["headers"].get(name) is None:
111111
_options["headers"][name] = value
112112

113113
return _options

test/api/open_fga_api_test.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2048,6 +2048,123 @@ async def test_read_with_type_only_object(self, mock_request):
20482048
)
20492049
await api_client.close()
20502050

2051+
@patch.object(rest.RESTClientObject, "request")
2052+
async def test_check_custom_header_override_default_header(self, mock_request):
2053+
"""Test case for per-request custom header overriding default header
2054+
2055+
Per-request custom headers should override default headers with the same name
2056+
"""
2057+
2058+
# First, mock the response
2059+
response_body = '{"allowed": true}'
2060+
mock_request.return_value = mock_response(response_body, 200)
2061+
2062+
configuration = self.configuration
2063+
configuration.store_id = store_id
2064+
async with openfga_sdk.ApiClient(configuration) as api_client:
2065+
# Set a default header
2066+
api_client.set_default_header("X-Custom-Header", "default-value")
2067+
api_instance = open_fga_api.OpenFgaApi(api_client)
2068+
body = CheckRequest(
2069+
tuple_key=TupleKey(
2070+
object="document:2021-budget",
2071+
relation="reader",
2072+
user="user:81684243-9356-4421-8fbf-a4f8d36aa31b",
2073+
),
2074+
)
2075+
# Make request with per-request custom header that should override the default
2076+
api_response = await api_instance.check(
2077+
body=body,
2078+
_headers={"X-Custom-Header": "per-request-value"},
2079+
)
2080+
self.assertIsInstance(api_response, CheckResponse)
2081+
self.assertTrue(api_response.allowed)
2082+
# Make sure the API was called with the per-request header value, not the default
2083+
expected_headers = urllib3.response.HTTPHeaderDict(
2084+
{
2085+
"Accept": "application/json",
2086+
"Content-Type": "application/json",
2087+
"User-Agent": "openfga-sdk python/0.9.6",
2088+
"X-Custom-Header": "per-request-value", # Should be the per-request value
2089+
}
2090+
)
2091+
mock_request.assert_called_once_with(
2092+
"POST",
2093+
"http://api.fga.example/stores/01H0H015178Y2V4CX10C2KGHF4/check",
2094+
headers=expected_headers,
2095+
query_params=[],
2096+
post_params=[],
2097+
body={
2098+
"tuple_key": {
2099+
"object": "document:2021-budget",
2100+
"relation": "reader",
2101+
"user": "user:81684243-9356-4421-8fbf-a4f8d36aa31b",
2102+
}
2103+
},
2104+
_preload_content=ANY,
2105+
_request_timeout=None,
2106+
)
2107+
2108+
@patch.object(rest.RESTClientObject, "request")
2109+
async def test_check_per_request_header_and_default_header_coexist(
2110+
self, mock_request
2111+
):
2112+
"""Test case for per-request custom header and default header coexisting
2113+
2114+
Per-request custom headers should be merged with default headers
2115+
"""
2116+
2117+
# First, mock the response
2118+
response_body = '{"allowed": true}'
2119+
mock_request.return_value = mock_response(response_body, 200)
2120+
2121+
configuration = self.configuration
2122+
configuration.store_id = store_id
2123+
async with openfga_sdk.ApiClient(configuration) as api_client:
2124+
# Set a default header
2125+
api_client.set_default_header("X-Default-Header", "default-value")
2126+
api_instance = open_fga_api.OpenFgaApi(api_client)
2127+
body = CheckRequest(
2128+
tuple_key=TupleKey(
2129+
object="document:2021-budget",
2130+
relation="reader",
2131+
user="user:81684243-9356-4421-8fbf-a4f8d36aa31b",
2132+
),
2133+
)
2134+
# Make request with per-request custom header (different from default)
2135+
api_response = await api_instance.check(
2136+
body=body,
2137+
_headers={"X-Per-Request-Header": "per-request-value"},
2138+
)
2139+
self.assertIsInstance(api_response, CheckResponse)
2140+
self.assertTrue(api_response.allowed)
2141+
# Make sure both headers are present in the request
2142+
expected_headers = urllib3.response.HTTPHeaderDict(
2143+
{
2144+
"Accept": "application/json",
2145+
"Content-Type": "application/json",
2146+
"User-Agent": "openfga-sdk python/0.9.6",
2147+
"X-Default-Header": "default-value", # Default header preserved
2148+
"X-Per-Request-Header": "per-request-value", # Per-request header added
2149+
}
2150+
)
2151+
mock_request.assert_called_once_with(
2152+
"POST",
2153+
"http://api.fga.example/stores/01H0H015178Y2V4CX10C2KGHF4/check",
2154+
headers=expected_headers,
2155+
query_params=[],
2156+
post_params=[],
2157+
body={
2158+
"tuple_key": {
2159+
"object": "document:2021-budget",
2160+
"relation": "reader",
2161+
"user": "user:81684243-9356-4421-8fbf-a4f8d36aa31b",
2162+
}
2163+
},
2164+
_preload_content=ANY,
2165+
_request_timeout=None,
2166+
)
2167+
20512168

20522169
if __name__ == "__main__":
20532170
unittest.main()

test/client/client_test.py

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121

2222
from openfga_sdk import rest
2323
from openfga_sdk.client import ClientConfiguration
24-
from openfga_sdk.client.client import OpenFgaClient
24+
from openfga_sdk.client.client import OpenFgaClient, set_heading_if_not_set
2525
from openfga_sdk.client.models.assertion import ClientAssertion
2626
from openfga_sdk.client.models.batch_check_item import ClientBatchCheckItem
2727
from openfga_sdk.client.models.batch_check_request import ClientBatchCheckRequest
@@ -3273,3 +3273,100 @@ def test_configuration_authorization_model_id_invalid(self):
32733273
authorization_model_id="abcd",
32743274
)
32753275
self.assertRaises(FgaValidationException, configuration.is_valid)
3276+
3277+
def test_set_heading_if_not_set_when_none_provided(self):
3278+
"""Should set header when no options provided"""
3279+
result = set_heading_if_not_set(None, "X-Test-Header", "default-value")
3280+
3281+
self.assertIsNotNone(result)
3282+
self.assertIn("headers", result)
3283+
self.assertEqual(result["headers"]["X-Test-Header"], "default-value")
3284+
3285+
def test_set_heading_if_not_set_when_empty_options_provided(self):
3286+
"""Should set header when empty options dict provided"""
3287+
result = set_heading_if_not_set({}, "X-Test-Header", "default-value")
3288+
3289+
self.assertIn("headers", result)
3290+
self.assertEqual(result["headers"]["X-Test-Header"], "default-value")
3291+
3292+
def test_set_heading_if_not_set_when_no_headers_in_options(self):
3293+
"""Should set header when options dict has no headers key"""
3294+
options = {"page_size": 10}
3295+
result = set_heading_if_not_set(options, "X-Test-Header", "default-value")
3296+
3297+
self.assertIn("headers", result)
3298+
self.assertEqual(result["headers"]["X-Test-Header"], "default-value")
3299+
self.assertEqual(result["page_size"], 10)
3300+
3301+
def test_set_heading_if_not_set_when_headers_empty(self):
3302+
"""Should set header when headers dict is empty"""
3303+
options = {"headers": {}}
3304+
result = set_heading_if_not_set(options, "X-Test-Header", "default-value")
3305+
3306+
self.assertEqual(result["headers"]["X-Test-Header"], "default-value")
3307+
3308+
def test_set_heading_if_not_set_does_not_override_existing_custom_header(self):
3309+
"""Should NOT override when custom header already exists - this is the critical test for the bug fix"""
3310+
options = {"headers": {"X-Test-Header": "custom-value"}}
3311+
result = set_heading_if_not_set(options, "X-Test-Header", "default-value")
3312+
3313+
# Custom header should be preserved, NOT overridden by default
3314+
self.assertEqual(result["headers"]["X-Test-Header"], "custom-value")
3315+
3316+
def test_set_heading_if_not_set_preserves_other_headers_when_setting_new_header(
3317+
self,
3318+
):
3319+
"""Should preserve existing headers when setting a new one"""
3320+
options = {"headers": {"X-Existing-Header": "existing-value"}}
3321+
result = set_heading_if_not_set(options, "X-New-Header", "new-value")
3322+
3323+
self.assertEqual(result["headers"]["X-Existing-Header"], "existing-value")
3324+
self.assertEqual(result["headers"]["X-New-Header"], "new-value")
3325+
3326+
def test_set_heading_if_not_set_handles_integer_header_values(self):
3327+
"""Should not override existing integer header values"""
3328+
options = {"headers": {"X-Retry-Count": 5}}
3329+
result = set_heading_if_not_set(options, "X-Retry-Count", 1)
3330+
3331+
# Existing integer value should be preserved
3332+
self.assertEqual(result["headers"]["X-Retry-Count"], 5)
3333+
3334+
def test_set_heading_if_not_set_handles_non_dict_headers_value(self):
3335+
"""Should convert non-dict headers value to dict"""
3336+
options = {"headers": "invalid"}
3337+
result = set_heading_if_not_set(options, "X-Test-Header", "default-value")
3338+
3339+
self.assertIsInstance(result["headers"], dict)
3340+
self.assertEqual(result["headers"]["X-Test-Header"], "default-value")
3341+
3342+
def test_set_heading_if_not_set_does_not_mutate_when_header_exists(self):
3343+
"""Should return same dict when header already exists"""
3344+
options = {"headers": {"X-Test-Header": "custom-value"}}
3345+
original_value = options["headers"]["X-Test-Header"]
3346+
3347+
result = set_heading_if_not_set(options, "X-Test-Header", "default-value")
3348+
3349+
# Should return the same modified dict
3350+
self.assertIs(result, options)
3351+
# Value should not have changed
3352+
self.assertEqual(result["headers"]["X-Test-Header"], original_value)
3353+
3354+
def test_set_heading_if_not_set_multiple_headers_with_mixed_states(self):
3355+
"""Should handle multiple headers, some existing and some new"""
3356+
options = {
3357+
"headers": {
3358+
"X-Custom-Header": "custom-value",
3359+
"X-Another-Header": "another-value",
3360+
}
3361+
}
3362+
3363+
# Try to set a custom header (should not override)
3364+
result = set_heading_if_not_set(options, "X-Custom-Header", "default-value")
3365+
self.assertEqual(result["headers"]["X-Custom-Header"], "custom-value")
3366+
3367+
# Try to set a new header (should be added)
3368+
result = set_heading_if_not_set(result, "X-New-Header", "new-value")
3369+
self.assertEqual(result["headers"]["X-New-Header"], "new-value")
3370+
3371+
# Original headers should still exist
3372+
self.assertEqual(result["headers"]["X-Another-Header"], "another-value")

test/sync/client/client_test.py

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@
8585
WriteAuthorizationModelResponse,
8686
)
8787
from openfga_sdk.sync import rest
88-
from openfga_sdk.sync.client.client import OpenFgaClient
88+
from openfga_sdk.sync.client.client import OpenFgaClient, set_heading_if_not_set
8989

9090

9191
store_id = "01YCP46JKYM8FJCQ37NMBYHE5X"
@@ -3275,3 +3275,100 @@ def test_configuration_authorization_model_id_invalid(self):
32753275
authorization_model_id="abcd",
32763276
)
32773277
self.assertRaises(FgaValidationException, configuration.is_valid)
3278+
3279+
def test_set_heading_if_not_set_when_none_provided(self):
3280+
"""Should set header when no options provided"""
3281+
result = set_heading_if_not_set(None, "X-Test-Header", "default-value")
3282+
3283+
self.assertIsNotNone(result)
3284+
self.assertIn("headers", result)
3285+
self.assertEqual(result["headers"]["X-Test-Header"], "default-value")
3286+
3287+
def test_set_heading_if_not_set_when_empty_options_provided(self):
3288+
"""Should set header when empty options dict provided"""
3289+
result = set_heading_if_not_set({}, "X-Test-Header", "default-value")
3290+
3291+
self.assertIn("headers", result)
3292+
self.assertEqual(result["headers"]["X-Test-Header"], "default-value")
3293+
3294+
def test_set_heading_if_not_set_when_no_headers_in_options(self):
3295+
"""Should set header when options dict has no headers key"""
3296+
options = {"page_size": 10}
3297+
result = set_heading_if_not_set(options, "X-Test-Header", "default-value")
3298+
3299+
self.assertIn("headers", result)
3300+
self.assertEqual(result["headers"]["X-Test-Header"], "default-value")
3301+
self.assertEqual(result["page_size"], 10)
3302+
3303+
def test_set_heading_if_not_set_when_headers_empty(self):
3304+
"""Should set header when headers dict is empty"""
3305+
options = {"headers": {}}
3306+
result = set_heading_if_not_set(options, "X-Test-Header", "default-value")
3307+
3308+
self.assertEqual(result["headers"]["X-Test-Header"], "default-value")
3309+
3310+
def test_set_heading_if_not_set_does_not_override_existing_custom_header(self):
3311+
"""Should NOT override when custom header already exists - this is the critical test for the bug fix"""
3312+
options = {"headers": {"X-Test-Header": "custom-value"}}
3313+
result = set_heading_if_not_set(options, "X-Test-Header", "default-value")
3314+
3315+
# Custom header should be preserved, NOT overridden by default
3316+
self.assertEqual(result["headers"]["X-Test-Header"], "custom-value")
3317+
3318+
def test_set_heading_if_not_set_preserves_other_headers_when_setting_new_header(
3319+
self,
3320+
):
3321+
"""Should preserve existing headers when setting a new one"""
3322+
options = {"headers": {"X-Existing-Header": "existing-value"}}
3323+
result = set_heading_if_not_set(options, "X-New-Header", "new-value")
3324+
3325+
self.assertEqual(result["headers"]["X-Existing-Header"], "existing-value")
3326+
self.assertEqual(result["headers"]["X-New-Header"], "new-value")
3327+
3328+
def test_set_heading_if_not_set_handles_integer_header_values(self):
3329+
"""Should not override existing integer header values"""
3330+
options = {"headers": {"X-Retry-Count": 5}}
3331+
result = set_heading_if_not_set(options, "X-Retry-Count", 1)
3332+
3333+
# Existing integer value should be preserved
3334+
self.assertEqual(result["headers"]["X-Retry-Count"], 5)
3335+
3336+
def test_set_heading_if_not_set_handles_non_dict_headers_value(self):
3337+
"""Should convert non-dict headers value to dict"""
3338+
options = {"headers": "invalid"}
3339+
result = set_heading_if_not_set(options, "X-Test-Header", "default-value")
3340+
3341+
self.assertIsInstance(result["headers"], dict)
3342+
self.assertEqual(result["headers"]["X-Test-Header"], "default-value")
3343+
3344+
def test_set_heading_if_not_set_does_not_mutate_when_header_exists(self):
3345+
"""Should return same dict when header already exists"""
3346+
options = {"headers": {"X-Test-Header": "custom-value"}}
3347+
original_value = options["headers"]["X-Test-Header"]
3348+
3349+
result = set_heading_if_not_set(options, "X-Test-Header", "default-value")
3350+
3351+
# Should return the same modified dict
3352+
self.assertIs(result, options)
3353+
# Value should not have changed
3354+
self.assertEqual(result["headers"]["X-Test-Header"], original_value)
3355+
3356+
def test_set_heading_if_not_set_multiple_headers_with_mixed_states(self):
3357+
"""Should handle multiple headers, some existing and some new"""
3358+
options = {
3359+
"headers": {
3360+
"X-Custom-Header": "custom-value",
3361+
"X-Another-Header": "another-value",
3362+
}
3363+
}
3364+
3365+
# Try to set a custom header (should not override)
3366+
result = set_heading_if_not_set(options, "X-Custom-Header", "default-value")
3367+
self.assertEqual(result["headers"]["X-Custom-Header"], "custom-value")
3368+
3369+
# Try to set a new header (should be added)
3370+
result = set_heading_if_not_set(result, "X-New-Header", "new-value")
3371+
self.assertEqual(result["headers"]["X-New-Header"], "new-value")
3372+
3373+
# Original headers should still exist
3374+
self.assertEqual(result["headers"]["X-Another-Header"], "another-value")

0 commit comments

Comments
 (0)