Skip to content

Commit c229791

Browse files
authored
fix(python): merge request headers case-insensitively (#25163)
* fix(python): merge HTTPX request headers case-insensitively * fix(python): use consistent header merging across client libraries * fix(python): align user agent getter with header merge * test(python): isolate header tests by transport --------- Co-authored-by: danielpassy <20033745+danielpassy@users.noreply.github.com>
1 parent f2697e5 commit c229791

112 files changed

Lines changed: 3501 additions & 1465 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎modules/openapi-generator/src/main/resources/python/api.mustache‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -308,7 +308,7 @@ https://github.com/OpenAPITools/openapi-generator/blob/c84b949df1a9ec04ba75989cb
308308

309309
_path_params: Dict[str, str] = {}
310310
_query_params: List[Tuple[str, str]] = []
311-
_header_params: Dict[str, Optional[str]] = _headers or {}
311+
_header_params: Dict[str, Any] = self.api_client._merge_headers(_headers)
312312
_form_params: List[Tuple[str, str]] = []
313313
_files: Dict[
314314
str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]]
@@ -387,12 +387,12 @@ https://github.com/OpenAPITools/openapi-generator/blob/c84b949df1a9ec04ba75989cb
387387
{{#headerParams}}
388388
if {{paramName}} is not None:
389389
{{#vendorExtensions.x-python-json-header}}
390-
_header_params['{{baseName}}'] = json.dumps(
390+
self.api_client._set_header(_header_params, '{{baseName}}', json.dumps(
391391
self.api_client.sanitize_for_serialization({{paramName}})
392-
)
392+
))
393393
{{/vendorExtensions.x-python-json-header}}
394394
{{^vendorExtensions.x-python-json-header}}
395-
_header_params['{{baseName}}'] = {{paramName}}
395+
self.api_client._set_header(_header_params, '{{baseName}}', {{paramName}})
396396
{{/vendorExtensions.x-python-json-header}}
397397
{{/headerParams}}
398398
# process the form parameters
@@ -431,13 +431,13 @@ https://github.com/OpenAPITools/openapi-generator/blob/c84b949df1a9ec04ba75989cb
431431
{{/isQueryParam}}
432432
{{#isHeaderParam}}
433433
# Set client side default value of Header Param "{{baseName}}".
434-
_header_params['{{baseName}}'] = {{#_enum}}'{{{.}}}'{{/_enum}}
434+
self.api_client._set_header(_header_params, '{{baseName}}', {{#_enum}}'{{{.}}}'{{/_enum}})
435435
{{/isHeaderParam}}
436436
{{/constantParams}}
437437

438438
{{#hasProduces}}
439439
# set the HTTP header `Accept`
440-
if 'Accept' not in _header_params:
440+
if not any(key.lower() == 'accept' for key in _header_params):
441441
_header_params['Accept'] = self.api_client.select_header_accept(
442442
[{{#produces}}
443443
'{{{mediaType}}}'{{^-last}}, {{/-last}}{{/produces}}
@@ -448,7 +448,7 @@ https://github.com/OpenAPITools/openapi-generator/blob/c84b949df1a9ec04ba75989cb
448448
{{#hasConsumes}}
449449
# set the HTTP header `Content-Type`
450450
if _content_type:
451-
_header_params['Content-Type'] = _content_type
451+
self.api_client._set_header(_header_params, 'Content-Type', _content_type)
452452
else:
453453
_default_content_type = (
454454
self.api_client.select_header_content_type(
@@ -458,7 +458,7 @@ https://github.com/OpenAPITools/openapi-generator/blob/c84b949df1a9ec04ba75989cb
458458
)
459459
)
460460
if _default_content_type is not None:
461-
_header_params['Content-Type'] = _default_content_type
461+
self.api_client._set_header(_header_params, 'Content-Type', _default_content_type)
462462
{{/hasConsumes}}
463463

464464
# authentication setting

‎modules/openapi-generator/src/main/resources/python/api_client.mustache‎

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ from threading import Lock
2020
{{/compatibleWithPythonLegacyApi}}
2121

2222
from urllib.parse import quote
23-
from typing import Tuple, Optional, List, Dict, Union{{#compatibleWithPythonLegacy}}, Any{{/compatibleWithPythonLegacy}}
23+
from typing import Any, Tuple, Optional, List, Dict, Union
2424
from pydantic import SecretStr
2525

2626
from {{packageName}}.configuration import Configuration
@@ -231,16 +231,36 @@ https://github.com/OpenAPITools/openapi-generator/blob/c84b949df1a9ec04ba75989cb
231231
@property
232232
def user_agent(self):
233233
"""User agent for this API client"""
234-
return self.default_headers['User-Agent']
234+
for name, value in reversed(self.default_headers.items()):
235+
if name.lower() == 'user-agent':
236+
return value
237+
raise KeyError('User-Agent')
235238

236239
@user_agent.setter
237240
def user_agent(self, value):
238-
self.default_headers['User-Agent'] = value
241+
self._set_header(self.default_headers, 'User-Agent', value)
239242

240243
def set_default_header(self, header_name, header_value):
241-
self.default_headers[header_name] = header_value
244+
self._set_header(self.default_headers, header_name, header_value)
242245

243246

247+
@staticmethod
248+
def _set_header(headers: Dict[str, Any], name: str, value: Any) -> None:
249+
"""Replace a header case-insensitively, retaining the winning spelling."""
250+
for key in list(headers):
251+
if key.lower() == name.lower():
252+
del headers[key]
253+
headers[name] = value
254+
255+
@classmethod
256+
def _merge_headers(cls, *sources: Optional[Dict[str, Any]]) -> Dict[str, Any]:
257+
"""Copy headers; later sources and later entries in each dict win."""
258+
headers: Dict[str, Any] = {}
259+
for source in sources:
260+
for name, value in (source or {}).items():
261+
cls._set_header(headers, name, value)
262+
return headers
263+
244264
_default = None
245265

246266
{{#useIndependentImplicitClients}}
@@ -321,10 +341,9 @@ https://github.com/OpenAPITools/openapi-generator/blob/c84b949df1a9ec04ba75989cb
321341
config = self.configuration
322342

323343
# header parameters
324-
header_params = header_params or {}
325-
header_params.update(self.default_headers)
344+
header_params = self._merge_headers(header_params, self.default_headers)
326345
if self.cookie:
327-
header_params['Cookie'] = self.cookie
346+
self._set_header(header_params, 'Cookie', self.cookie)
328347
if header_params:
329348
header_params = self.sanitize_for_serialization(header_params)
330349
header_params = dict(
@@ -856,7 +875,11 @@ https://github.com/OpenAPITools/openapi-generator/blob/c84b949df1a9ec04ba75989cb
856875
:param auth_setting: auth settings for the endpoint
857876
"""
858877
if auth_setting['in'] == 'cookie':
859-
if not 'Cookie' in headers:
878+
for key in list(headers):
879+
if key.lower() == 'cookie':
880+
self._set_header(headers, 'Cookie', headers[key])
881+
break
882+
if not headers.get('Cookie'):
860883
headers['Cookie'] = ""
861884
else:
862885
headers['Cookie'] += "; "
@@ -865,15 +888,16 @@ https://github.com/OpenAPITools/openapi-generator/blob/c84b949df1a9ec04ba75989cb
865888
headers['Cookie'] += f"{auth_setting['key']}={cookie_value}"
866889
elif auth_setting['in'] == 'header':
867890
if auth_setting['type'] != 'http-signature':
868-
headers[auth_setting['key']] = auth_setting['value']
891+
self._set_header(headers, auth_setting['key'], auth_setting['value'])
869892
{{#hasHttpSignatureMethods}}
870893
else:
871894
# The HTTP signature scheme requires multiple HTTP headers
872895
# that are calculated dynamically.
873896
signing_info = self.configuration.signing_info
874897
auth_headers = signing_info.get_http_signature_headers(
875898
resource_path, method, headers, body, queries)
876-
headers.update(auth_headers)
899+
for name, value in auth_headers.items():
900+
self._set_header(headers, name, value)
877901
{{/hasHttpSignatureMethods}}
878902
elif auth_setting['in'] == 'query':
879903
queries.append((auth_setting['key'], auth_setting['value']))

‎modules/openapi-generator/src/main/resources/python/asyncio/rest.mustache‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ from typing import Any, Dict, Optional, Union
1111

1212
import aiohttp
1313
import aiohttp_retry
14+
from multidict import CIMultiDict
1415

1516
from {{packageName}}.exceptions import ApiException, ApiValueError
1617

@@ -191,7 +192,7 @@ class RESTClientObject:
191192
)
192193

193194
post_params = post_params or {}
194-
headers = headers or {}
195+
headers = CIMultiDict(headers or {})
195196
# url already contains the URL query string
196197
timeout = _request_timeout or 5 * 60
197198

‎modules/openapi-generator/src/main/resources/python/common_README.mustache‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,3 +108,33 @@ Class | Method | HTTP request | Description
108108

109109
{{#apiInfo}}{{#apis}}{{#-last}}{{infoEmail}}
110110
{{/-last}}{{/apis}}{{/apiInfo}}
111+
112+
## Request headers
113+
114+
Header names are compared case-insensitively in all Python client libraries.
115+
Request headers are copied; supplying `_headers` does not change the caller's dict
116+
or the client's shared defaults. The public `_headers` argument retains its dict
117+
validation and existing value serialization. Pydantic can coerce mappings such as HTTPX `Headers` to a dict,
118+
combining repeated fields into a single value; `_headers` does not provide a
119+
repeated-field or list-of-pairs API.
120+
121+
Precedence, from lowest to highest:
122+
123+
1. `_headers` (within one dict, the last inserted case variant wins).
124+
2. Explicit OpenAPI header parameters and generated `Content-Type` (or `_content_type`).
125+
Generated `Accept` is added only when neither `_headers` nor explicit header
126+
parameters contain an `Accept` case variant.
127+
3. `ApiClient.default_headers` (including headers set with `set_default_header`).
128+
4. The client's `cookie` setting, for `Cookie`.
129+
5. Authentication headers. A non-empty `_request_auth` replaces configured
130+
authentication for that call; `{}` retains configured authentication. Cookie
131+
authentication appends to the selected `Cookie` value.
132+
133+
This retains the existing precedence for identically spelled names and extends it
134+
to case variants. To override a generated content type, use `_content_type`;
135+
client defaults still take precedence over it. The winning header's spelling and
136+
value are retained for replacements. Cookie authentication uses the canonical
137+
`Cookie` spelling and appends its value. Values containing legitimate
138+
comma-separated lists are not split or deduplicated. This does not remove
139+
duplicate values inside a header or change the handling of repeated fields
140+
supplied directly to the transport.

‎modules/openapi-generator/src/main/resources/python/httpx/rest.mustache‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ class RESTClientObject:
110110
)
111111

112112
post_params = post_params or {}
113-
headers = headers or {}
113+
headers = {{httpxModule}}.Headers(headers or {})
114114
timeout = _request_timeout or 5 * 60
115115

116116
if 'Content-Type' not in headers:

‎modules/openapi-generator/src/main/resources/python/rest.mustache‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ class RESTClientObject:
212212
)
213213

214214
post_params = post_params or {}
215-
headers = headers or {}
215+
headers = urllib3.HTTPHeaderDict(headers or {})
216216

217217
timeout = None
218218
if _request_timeout:

‎modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonClientCodegenTest.java‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -174,9 +174,9 @@ public void testJsonContentHeaderUsesJsonSerialization() throws IOException {
174174
TestUtils.assertFileContains(
175175
api,
176176
"import json",
177-
"_header_params['X-Json-Arg'] = json.dumps(",
177+
"self.api_client._set_header(_header_params, 'X-Json-Arg', json.dumps(",
178178
"self.api_client.sanitize_for_serialization(x_json_arg)",
179-
"_header_params['X-Plain-Arg'] = x_plain_arg");
179+
"self.api_client._set_header(_header_params, 'X-Plain-Arg', x_plain_arg)");
180180

181181
TestUtils.assertFileNotContains(
182182
api,
@@ -834,7 +834,7 @@ public void testHandleConstantParams() throws IOException {
834834
File apiFile = files
835835
.get(Paths.get(output.getAbsolutePath(), "openapi_client", "api", "hello_example_api.py").toString());
836836
assertNotNull(apiFile);
837-
assertFileContains(apiFile.toPath(), "_header_params['X-CUSTOM_CONSTANT_HEADER'] = 'CONSTANT_VALUE'");
837+
assertFileContains(apiFile.toPath(), "self.api_client._set_header(_header_params, 'X-CUSTOM_CONSTANT_HEADER', 'CONSTANT_VALUE')");
838838
assertFileContains(apiFile.toPath(), "_query_params.append(('CONSTANT_QUERY_STRING_KEY', 'CONSTANT_QUERY_STRING_VALUE'))");
839839
}
840840

‎samples/client/echo_api/python-disallowAdditionalPropertiesIfNotPresent/README.md‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,3 +165,33 @@ Authentication schemes defined for the API:
165165

166166
team@openapitools.org
167167

168+
169+
## Request headers
170+
171+
Header names are compared case-insensitively in all Python client libraries.
172+
Request headers are copied; supplying `_headers` does not change the caller's dict
173+
or the client's shared defaults. The public `_headers` argument retains its dict
174+
validation and existing value serialization. Pydantic can coerce mappings such as HTTPX `Headers` to a dict,
175+
combining repeated fields into a single value; `_headers` does not provide a
176+
repeated-field or list-of-pairs API.
177+
178+
Precedence, from lowest to highest:
179+
180+
1. `_headers` (within one dict, the last inserted case variant wins).
181+
2. Explicit OpenAPI header parameters and generated `Content-Type` (or `_content_type`).
182+
Generated `Accept` is added only when neither `_headers` nor explicit header
183+
parameters contain an `Accept` case variant.
184+
3. `ApiClient.default_headers` (including headers set with `set_default_header`).
185+
4. The client's `cookie` setting, for `Cookie`.
186+
5. Authentication headers. A non-empty `_request_auth` replaces configured
187+
authentication for that call; `{}` retains configured authentication. Cookie
188+
authentication appends to the selected `Cookie` value.
189+
190+
This retains the existing precedence for identically spelled names and extends it
191+
to case variants. To override a generated content type, use `_content_type`;
192+
client defaults still take precedence over it. The winning header's spelling and
193+
value are retained for replacements. Cookie authentication uses the canonical
194+
`Cookie` spelling and appends its value. Values containing legitimate
195+
comma-separated lists are not split or deduplicated. This does not remove
196+
duplicate values inside a header or change the handling of repeated fields
197+
supplied directly to the transport.

‎samples/client/echo_api/python-disallowAdditionalPropertiesIfNotPresent/openapi_client/api/auth_api.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,7 @@ def _test_auth_http_basic_serialize(
236236

237237
_path_params: Dict[str, str] = {}
238238
_query_params: List[Tuple[str, str]] = []
239-
_header_params: Dict[str, Optional[str]] = _headers or {}
239+
_header_params: Dict[str, Any] = self.api_client._merge_headers(_headers)
240240
_form_params: List[Tuple[str, str]] = []
241241
_files: Dict[
242242
str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]]
@@ -251,7 +251,7 @@ def _test_auth_http_basic_serialize(
251251

252252

253253
# set the HTTP header `Accept`
254-
if 'Accept' not in _header_params:
254+
if not any(key.lower() == 'accept' for key in _header_params):
255255
_header_params['Accept'] = self.api_client.select_header_accept(
256256
[
257257
'text/plain'
@@ -482,7 +482,7 @@ def _test_auth_http_bearer_serialize(
482482

483483
_path_params: Dict[str, str] = {}
484484
_query_params: List[Tuple[str, str]] = []
485-
_header_params: Dict[str, Optional[str]] = _headers or {}
485+
_header_params: Dict[str, Any] = self.api_client._merge_headers(_headers)
486486
_form_params: List[Tuple[str, str]] = []
487487
_files: Dict[
488488
str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]]
@@ -497,7 +497,7 @@ def _test_auth_http_bearer_serialize(
497497

498498

499499
# set the HTTP header `Accept`
500-
if 'Accept' not in _header_params:
500+
if not any(key.lower() == 'accept' for key in _header_params):
501501
_header_params['Accept'] = self.api_client.select_header_accept(
502502
[
503503
'text/plain'

0 commit comments

Comments
 (0)