Skip to content

Commit 6357dc0

Browse files
iscai-msftiscai-msftCopilot
authored
[python] support custom wire names for etags defined with Azure.Core.eTag (#10494)
## Problem Custom etag-typed headers (e.g. `x-ms-if-blob-match`, `x-ms-if-blob-none-match`) were being assigned to the wrong if-match/if-none-match slot during preprocessing. The slot assignment and the per-parameter conversion used different logic to classify headers, and synthetic partners inherited incorrect wire names. ## Changes - **Added `_etag_kind()` helper** — centralizes etag classification using `none-match` substring (stricter than the previous `none` check which could false-positive on unrelated headers) - **Fixed slot assignment** — uses `_etag_kind()` to correctly classify custom etag headers before pairing - **Fixed synthetic partner creation** — strips `isEtag` from synthetic clones to prevent re-conversion with wrong `originalWireName`, falls back to standard `If-Match`/`If-None-Match` wire names - **Unified classification** — both slot assignment and per-parameter conversion now use the same `_etag_kind()` logic --------- Co-authored-by: iscai-msft <isabellavcai@gmail.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent b3ccea9 commit 6357dc0

5 files changed

Lines changed: 86 additions & 37 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
changeKind: fix
3+
packages:
4+
- "@typespec/http-client-python"
5+
---
6+
7+
Support custom wire names for etags defined with `Azure.Core.eTag` (e.g. `x-ms-blob-if-match`)

packages/http-client-python/emitter/src/http.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { NoTarget } from "@typespec/compiler";
1+
import { getNamespaceFullName, NoTarget } from "@typespec/compiler";
22

33
import {
44
getHttpOperationParameter,
@@ -19,6 +19,7 @@ import {
1919
SdkQueryParameter,
2020
SdkServiceMethod,
2121
SdkServiceResponseHeader,
22+
SdkType,
2223
UsageFlags,
2324
} from "@azure-tools/typespec-client-generator-core";
2425
import { HttpStatusCodeRange } from "@typespec/http";
@@ -41,6 +42,32 @@ export enum ReferredByOperationTypes {
4142
NonPagingOnly = 2,
4243
}
4344

45+
function isEtagType(type: SdkType): boolean {
46+
if (type.kind === "nullable") return isEtagType(type.type);
47+
const raw = type.__raw;
48+
if (!raw || raw.kind !== "Scalar") return false;
49+
return (
50+
raw.name === "eTag" &&
51+
raw.namespace !== undefined &&
52+
getNamespaceFullName(raw.namespace) === "Azure.Core"
53+
);
54+
}
55+
56+
function getEtagRole(parameter: SdkHeaderParameter): string | undefined {
57+
const name = parameter.name.toLowerCase();
58+
const wire = parameter.serializedName.toLowerCase();
59+
// Standard If-Match / If-None-Match headers work with any type
60+
if (wire === "if-match") return "ifMatch";
61+
if (wire === "if-none-match") return "ifNoneMatch";
62+
// Non-standard headers require Azure.Core.eTag type
63+
if (!isEtagType(parameter.type)) return undefined;
64+
if (name.includes("nonematch") || name.includes("none_match")) return "ifNoneMatch";
65+
if (name.includes("match")) return "ifMatch";
66+
if (wire.endsWith("-if-none-match")) return "ifNoneMatch";
67+
if (wire.endsWith("-if-match")) return "ifMatch";
68+
return undefined;
69+
}
70+
4471
function isContentTypeParameter(parameter: SdkHeaderParameter) {
4572
return parameter.serializedName.toLowerCase() === "content-type";
4673
}
@@ -496,6 +523,7 @@ function emitHttpHeaderParameter(
496523
delimiter,
497524
explode,
498525
clientDefaultValue,
526+
etagRole: getEtagRole(parameter),
499527
};
500528
}
501529

packages/http-client-python/generator/pygen/codegen/models/parameter.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ def __init__(
6161
) -> None:
6262
super().__init__(yaml_data, code_model)
6363
self.wire_name: str = yaml_data.get("wireName", "")
64+
self.etag_role: Optional[str] = yaml_data.get("etagRole", None)
6465
self.client_name: str = self.yaml_data["clientName"]
6566
self.optional: bool = self.yaml_data["optional"]
6667
self.implementation: Optional[str] = yaml_data.get("implementation", None)

packages/http-client-python/generator/pygen/codegen/serializers/parameter_serializer.py

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -39,16 +39,6 @@ class PopKwargType(Enum):
3939
"client-request-id": [],
4040
"x-ms-client-request-id": [],
4141
"return-client-request-id": [],
42-
"etag": [
43-
"""if_match = prep_if_match(etag, match_condition)""",
44-
"""if if_match is not None:""",
45-
""" _headers["If-Match"] = _SERIALIZER.header("if_match", if_match, "str")""",
46-
],
47-
"match-condition": [
48-
"""if_none_match = prep_if_none_match(etag, match_condition)""",
49-
"""if if_none_match is not None:""",
50-
""" _headers["If-None-Match"] = _SERIALIZER.header("if_none_match", if_none_match, "str")""",
51-
],
5242
}
5343

5444

@@ -157,6 +147,20 @@ def serialize_query_header(
157147
):
158148
return SPECIAL_HEADER_SERIALIZATION[param.wire_name.lower()]
159149

150+
if not is_legacy and param.location == ParameterLocation.HEADER and param.etag_role is not None:
151+
header_name = param.wire_name
152+
if param.etag_role == "ifMatch":
153+
return [
154+
"""if_match = prep_if_match(etag, match_condition)""",
155+
"""if if_match is not None:""",
156+
f""" _headers["{header_name}"] = _SERIALIZER.header("if_match", if_match, "str")""",
157+
]
158+
return [
159+
"""if_none_match = prep_if_none_match(etag, match_condition)""",
160+
"""if if_none_match is not None:""",
161+
f""" _headers["{header_name}"] = _SERIALIZER.header("if_none_match", if_none_match, "str")""",
162+
]
163+
160164
set_parameter = "_{}['{}'] = {}".format(
161165
kwarg_name,
162166
param.wire_name,

packages/http-client-python/generator/pygen/preprocess/__init__.py

Lines changed: 35 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -132,20 +132,18 @@ def update_paging_response(yaml_data: dict[str, Any]) -> None:
132132
"client-request-id",
133133
"return-client-request-id",
134134
)
135-
HEADERS_CONVERT_IN_METHOD = {
136-
"if-match": {
137-
"clientName": "etag",
138-
"wireName": "etag",
139-
"description": "check if resource is changed. Set None to skip checking etag.",
140-
},
141-
"if-none-match": {
142-
"clientName": "match_condition",
143-
"wireName": "match-condition",
144-
"description": "The match condition to use upon the etag.",
145-
"type": {
146-
"type": "sdkcore",
147-
"name": "MatchConditions",
148-
},
135+
ETAG_MATCH_DATA = {
136+
"clientName": "etag",
137+
"etagRole": "ifMatch",
138+
"description": "check if resource is changed. Set None to skip checking etag.",
139+
}
140+
ETAG_NONE_MATCH_DATA = {
141+
"clientName": "match_condition",
142+
"etagRole": "ifNoneMatch",
143+
"description": "The match condition to use upon the etag.",
144+
"type": {
145+
"type": "sdkcore",
146+
"name": "MatchConditions",
149147
},
150148
}
151149
CLOUD_SETTING = {
@@ -166,6 +164,11 @@ def get_wire_name_lower(parameter: dict[str, Any]) -> str:
166164
return (parameter.get("wireName") or "").lower()
167165

168166

167+
def _get_etag_role(parameter: dict[str, Any]) -> Optional[str]:
168+
"""Return 'ifMatch', 'ifNoneMatch', or None for this header parameter."""
169+
return parameter.get("etagRole")
170+
171+
169172
def headers_convert(yaml_data: dict[str, Any], replace_data: Any) -> None:
170173
if isinstance(replace_data, dict):
171174
for k, v in replace_data.items():
@@ -281,9 +284,8 @@ def update_types(self, yaml_data: list[dict[str, Any]]) -> None:
281284
value["name"] = upper_name
282285

283286
# add type for reference
284-
for v in HEADERS_CONVERT_IN_METHOD.values():
285-
if isinstance(v, dict) and "type" in v:
286-
yaml_data.append(v["type"])
287+
if "type" in ETAG_NONE_MATCH_DATA:
288+
yaml_data.append(ETAG_NONE_MATCH_DATA["type"])
287289
yaml_data.append(CLOUD_SETTING["type"]) # type: ignore
288290

289291
def update_client(self, yaml_data: dict[str, Any]) -> None:
@@ -317,27 +319,30 @@ def update_client(self, yaml_data: dict[str, Any]) -> None:
317319
if p["location"] == "header" and wire_name_lower == "client-request-id":
318320
yaml_data["requestIdHeaderName"] = wire_name_lower
319321
if self.version_tolerant and p["location"] == "header":
320-
if wire_name_lower == "if-match":
322+
role = _get_etag_role(p)
323+
if role == "ifMatch" and not property_if_match:
321324
property_if_match = p
322-
elif wire_name_lower == "if-none-match":
325+
elif role == "ifNoneMatch" and not property_if_none_match:
323326
property_if_none_match = p
324327
# pylint: disable=line-too-long
325328
# some service(e.g. https://github.com/Azure/azure-rest-api-specs/blob/main/specification/cosmos-db/data-plane/Microsoft.Tables/preview/2019-02-02/table.json)
326329
# only has one, so we need to add "if-none-match" or "if-match" if it's missing
327330
if not property_if_match and property_if_none_match:
328331
property_if_match = property_if_none_match.copy()
329332
property_if_match["wireName"] = "if-match"
333+
property_if_match["etagRole"] = "ifMatch"
330334
if not property_if_none_match and property_if_match:
331335
property_if_none_match = property_if_match.copy()
332336
property_if_none_match["wireName"] = "if-none-match"
337+
property_if_none_match["etagRole"] = "ifNoneMatch"
333338

334339
if property_if_match and property_if_none_match:
335340
# arrange if-match and if-none-match to the end of parameters
336-
o["parameters"] = [
337-
item
338-
for item in o["parameters"]
339-
if get_wire_name_lower(item) not in ("if-match", "if-none-match")
340-
] + [property_if_match, property_if_none_match]
341+
etag_params = {id(property_if_match), id(property_if_none_match)}
342+
o["parameters"] = [item for item in o["parameters"] if id(item) not in etag_params] + [
343+
property_if_match,
344+
property_if_none_match,
345+
]
341346

342347
o["hasEtag"] = True
343348
yaml_data["hasEtag"] = True
@@ -372,8 +377,12 @@ def update_parameter(self, yaml_data: dict[str, Any]) -> None:
372377
wire_name_lower in HEADERS_HIDE_IN_METHOD or yaml_data.get("clientDefaultValue") == "multipart/form-data"
373378
):
374379
yaml_data["hideInMethod"] = True
375-
if self.version_tolerant and yaml_data["location"] == "header" and wire_name_lower in HEADERS_CONVERT_IN_METHOD:
376-
headers_convert(yaml_data, HEADERS_CONVERT_IN_METHOD[wire_name_lower])
380+
if self.version_tolerant and yaml_data["location"] == "header":
381+
role = _get_etag_role(yaml_data)
382+
if role == "ifMatch":
383+
headers_convert(yaml_data, ETAG_MATCH_DATA)
384+
elif role == "ifNoneMatch":
385+
headers_convert(yaml_data, ETAG_NONE_MATCH_DATA)
377386
if wire_name_lower in ["$host", "content-type", "accept"] and yaml_data["type"]["type"] == "constant":
378387
yaml_data["clientDefaultValue"] = yaml_data["type"]["value"]
379388

0 commit comments

Comments
 (0)