Skip to content

Commit 1908667

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

File tree

2 files changed

+109
-10
lines changed

2 files changed

+109
-10
lines changed

openapi_core/templating/paths/parsers.py

Lines changed: 107 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
14
from typing import Any
5+
from typing import Dict
6+
from typing import Optional
27

38
from parse import Parser
49

@@ -18,16 +23,115 @@ class PathParser(Parser): # type: ignore
1823
def __init__(
1924
self, pattern: str, pre_expression: str = "", post_expression: str = ""
2025
) -> None:
26+
self._orig_to_safe: Dict[str, str] = {}
27+
self._safe_to_orig: Dict[str, str] = {}
28+
self._safe_suffix_counters: Dict[str, int] = {}
2129
extra_types = {
2230
self.parse_path_parameter.name: self.parse_path_parameter
2331
}
24-
super().__init__(pattern, extra_types)
32+
sanitized_pattern = self._sanitize_pattern(pattern)
33+
super().__init__(sanitized_pattern, extra_types)
2534
self._expression: str = (
2635
pre_expression + self._expression + post_expression
2736
)
2837

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