Skip to content

Commit fa668f8

Browse files
committed
Tilda special character in path parameters fix
1 parent ec7ef3a commit fa668f8

File tree

2 files changed

+107
-10
lines changed

2 files changed

+107
-10
lines changed

openapi_core/templating/paths/parsers.py

Lines changed: 105 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
14
from typing import Any
25

36
from parse import Parser
@@ -18,16 +21,115 @@ class PathParser(Parser): # type: ignore
1821
def __init__(
1922
self, pattern: str, pre_expression: str = "", post_expression: str = ""
2023
) -> None:
24+
self._orig_to_safe: dict[str, str] = {}
25+
self._safe_to_orig: dict[str, str] = {}
26+
self._safe_suffix_counters: dict[str, int] = {}
2127
extra_types = {
2228
self.parse_path_parameter.name: self.parse_path_parameter
2329
}
24-
super().__init__(pattern, extra_types)
30+
sanitized_pattern = self._sanitize_pattern(pattern)
31+
super().__init__(sanitized_pattern, extra_types)
2532
self._expression: str = (
2633
pre_expression + self._expression + post_expression
2734
)
2835

36+
def search(self, string: str) -> Any:
37+
result = super().search(string)
38+
if not result:
39+
return result
40+
return _RemappedResult(result, self._safe_to_orig)
41+
42+
def parse(self, string: str, **kwargs: Any) -> Any:
43+
result = super().parse(string, **kwargs)
44+
if not result:
45+
return result
46+
return _RemappedResult(result, self._safe_to_orig)
47+
48+
def _get_safe_field_name(self, original: str) -> str:
49+
existing = self._orig_to_safe.get(original)
50+
if existing is not None:
51+
return existing
52+
53+
safe_parts = []
54+
for ch in original:
55+
if ch == "_" or ch.isalnum():
56+
safe_parts.append(ch)
57+
else:
58+
safe_parts.append(f"__{ord(ch):x}__")
59+
60+
safe = "".join(safe_parts) or "p"
61+
# `parse` and Python `re` named groups are most reliable when the group name
62+
# starts with a letter.
63+
if not safe[0].isalpha():
64+
safe = f"p_{safe}"
65+
66+
# Ensure uniqueness across fields within this parser
67+
if safe in self._safe_to_orig and self._safe_to_orig[safe] != original:
68+
base = safe
69+
suffix = self._safe_suffix_counters.get(base, 1)
70+
while True:
71+
candidate = f"{base}__{suffix}"
72+
if candidate not in self._safe_to_orig:
73+
safe = candidate
74+
self._safe_suffix_counters[base] = suffix + 1
75+
break
76+
suffix += 1
77+
78+
self._orig_to_safe[original] = safe
79+
self._safe_to_orig[safe] = original
80+
return safe
81+
82+
def _sanitize_pattern(self, pattern: str) -> str:
83+
# Pre-sanitize field names inside `{...}` before `parse` processes them.
84+
# This ensures special characters (e.g. `~`) and digit-leading names are
85+
# treated as named fields instead of literals or positional groups.
86+
if "{" not in pattern:
87+
return pattern
88+
89+
out: list[str] = []
90+
i = 0
91+
n = len(pattern)
92+
while i < n:
93+
ch = pattern[i]
94+
if ch != "{":
95+
out.append(ch)
96+
i += 1
97+
continue
98+
99+
end = pattern.find("}", i + 1)
100+
if end == -1:
101+
out.append(ch)
102+
i += 1
103+
continue
104+
105+
original = pattern[i + 1 : end]
106+
safe = self._get_safe_field_name(original)
107+
out.append("{")
108+
out.append(safe)
109+
out.append("}")
110+
i = end + 1
111+
112+
return "".join(out)
113+
29114
def _handle_field(self, field: str) -> Any:
30115
# handle as path parameter field
31-
field = field[1:-1]
32-
path_parameter_field = "{%s:PathParameter}" % field
116+
safe_field = field[1:-1]
117+
path_parameter_field = "{%s:PathParameter}" % safe_field
33118
return super()._handle_field(path_parameter_field)
119+
120+
121+
@dataclass(frozen=True)
122+
class _RemappedResult:
123+
_result: Any
124+
_safe_to_orig: dict[str, str]
125+
126+
@property
127+
def named(self) -> dict[str, Any]:
128+
named = getattr(self._result, "named", {})
129+
return {self._safe_to_orig.get(k, k): v for k, v in named.items()}
130+
131+
def __bool__(self) -> bool:
132+
return bool(self._result)
133+
134+
def __getattr__(self, item: str) -> Any:
135+
return getattr(self._result, item)

tests/unit/templating/test_paths_parsers.py

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ def test_exact(self):
3232
("/{test_id}/test", {"test_id": "test"}),
3333
("/{test.id}/test", {"test.id": "test"}),
3434
("/{test-id}/test", {"test-id": "test"}),
35+
("/{0test}/test", {"0test": "test"}),
3536
],
3637
)
3738
def test_chars_valid(self, path_pattern, expected):
@@ -42,17 +43,11 @@ def test_chars_valid(self, path_pattern, expected):
4243

4344
assert result.named == expected
4445

45-
@pytest.mark.xfail(
46-
reason=(
47-
"Special characters of regex not supported. "
48-
"See https://github.com/python-openapi/openapi-core/issues/672"
49-
),
50-
strict=True,
51-
)
5246
@pytest.mark.parametrize(
5347
"path_pattern,expected",
5448
[
5549
("/{test~id}/test", {"test~id": "test"}),
50+
("/{a-b~c.d}/test", {"a-b~c.d": "test"}),
5651
],
5752
)
5853
def test_special_chars_valid(self, path_pattern, expected):

0 commit comments

Comments
 (0)