Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .chronus/changes/fix-required-etag-header-2026-09-14.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: fix
packages:
- "@typespec/http-client-python"
---

Generate complete ETag handling for operations in nested operation groups.
Original file line number Diff line number Diff line change
Expand Up @@ -264,9 +264,6 @@ def _process_operation_etag_headers(
if_match_candidates: list[dict[str, Any]] = []
if_none_match_candidates: list[dict[str, Any]] = []
for p in operation["parameters"]:
wire_name_lower = get_wire_name_lower(p)
if p["location"] == "header" and wire_name_lower == "client-request-id":
client["requestIdHeaderName"] = wire_name_lower
if version_tolerant and p["location"] == "header":
role = _get_etag_role(p)
if role == "ifMatch":
Expand All @@ -275,7 +272,6 @@ def _process_operation_etag_headers(
if_none_match_candidates.append(p)

property_if_match, property_if_none_match = _resolve_etag_pair(if_match_candidates, if_none_match_candidates)

if property_if_match and property_if_none_match:
etag_params = {id(property_if_match), id(property_if_none_match)}
operation["parameters"] = [item for item in operation["parameters"] if id(item) not in etag_params] + [
Expand All @@ -286,6 +282,21 @@ def _process_operation_etag_headers(
client["hasEtag"] = True


def _process_operation_group_etag_headers(
operation_groups: list[dict[str, Any]],
client: dict[str, Any],
version_tolerant: bool,
) -> None:
for operation_group in operation_groups:
for operation in operation_group.get("operations", []):
_process_operation_etag_headers(operation, client, version_tolerant)
_process_operation_group_etag_headers(
operation_group.get("operationGroups", []),
client,
version_tolerant,
)


def headers_convert(yaml_data: dict[str, Any], replace_data: Any) -> None:
if isinstance(replace_data, dict):
for k, v in replace_data.items():
Expand Down Expand Up @@ -607,9 +618,16 @@ def update_client(self, yaml_data: dict[str, Any]) -> None:
if prop_name.endswith("Client"):
prop_name = prop_name[: len(prop_name) - len("Client")]
yaml_data["builderPadName"] = to_snake_case(prop_name)
for og in yaml_data.get("operationGroups", []):
for o in og["operations"]:
_process_operation_etag_headers(o, yaml_data, self.version_tolerant)
for operation_group in yaml_data.get("operationGroups", []):
for operation in operation_group.get("operations", []):
for parameter in operation["parameters"]:
if parameter["location"] == "header" and get_wire_name_lower(parameter) == "client-request-id":
yaml_data["requestIdHeaderName"] = "client-request-id"
_process_operation_group_etag_headers(
yaml_data.get("operationGroups", []),
yaml_data,
self.version_tolerant,
)

# add client signature cloud_setting for arm
if self.azure_arm and yaml_data["parameters"]:
Expand Down
53 changes: 51 additions & 2 deletions packages/http-client-python/tests/unit/test_preprocess_etag.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,18 @@ def _plugin() -> PreProcessPlugin:
)


def _header_param(client_name: str, wire_name: str, etag_role: str | None) -> dict:
def _header_param(
client_name: str,
wire_name: str,
etag_role: str | None,
*,
optional: bool = True,
) -> dict:
p: dict = {
"clientName": client_name,
"wireName": wire_name,
"location": "header",
"optional": True,
"optional": optional,
"implementation": "Method",
"type": {"type": "string"},
}
Expand Down Expand Up @@ -59,6 +65,49 @@ def _get_op(client: dict) -> dict:
return client["operationGroups"][0]["operations"][0]


def test_etag_headers_in_nested_operation_group_are_processed():
"""Nested ETag operations get their partner parameter and enable client helpers."""
if_match = _header_param(
"if_match",
"If-Match",
"ifMatch",
optional=False,
)
operation = {
"name": "remove",
"parameters": [if_match],
}
client = _client_yaml([])
parent_group = {
"operations": [],
"operationGroups": [
{
"operations": [operation],
}
],
}
client["operationGroups"] = [parent_group]

plugin = _plugin()
plugin.update_client(client)

assert client["hasEtag"] is True
assert "hasEtag" not in parent_group
assert operation["hasEtag"] is True
assert len(operation["parameters"]) == 2
assert operation["parameters"][0]["etagRole"] == "ifMatch"
assert operation["parameters"][1]["etagRole"] == "ifNoneMatch"
assert all(parameter["optional"] is False for parameter in operation["parameters"])
assert all("clientDefaultValue" not in parameter for parameter in operation["parameters"])

for parameter in operation["parameters"]:
plugin.update_parameter(parameter)
assert [parameter["clientName"] for parameter in operation["parameters"]] == [
"etag",
"match_condition",
]


def test_etag_role_preserved_when_only_standard_pair_present():
"""Standard If-Match/If-None-Match keep their etagRole."""
if_match = _header_param("if_match", "If-Match", "ifMatch")
Expand Down
Loading