Skip to content

Commit d1f6a24

Browse files
Add HTTP Traits
1 parent 66fbd7f commit d1f6a24

File tree

3 files changed

+199
-4
lines changed

3 files changed

+199
-4
lines changed

packages/smithy-core/src/smithy_core/traits.py

Lines changed: 117 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@
77
# they're correct regardless, so it's okay if the checks are stripped out.
88
# ruff: noqa: S101
99

10-
from dataclasses import dataclass
10+
from dataclasses import dataclass, field
1111
from enum import Enum
12-
from typing import TYPE_CHECKING, ClassVar
12+
from typing import TYPE_CHECKING, ClassVar, Mapping
1313

14-
from .types import TimestampFormat
14+
from .types import TimestampFormat, PathPattern
1515
from .shapes import ShapeID
1616

1717
if TYPE_CHECKING:
@@ -193,3 +193,117 @@ def __post_init__(self):
193193
@property
194194
def value(self) -> str:
195195
return self.document_value # type: ignore
196+
197+
198+
# TODO: Get all this moved over to the http package
199+
@dataclass(init=False, frozen=True)
200+
class HTTPTrait(Trait, id=ShapeID("smithy.api#http")):
201+
path: PathPattern = field(repr=False, hash=False, compare=False)
202+
code: int = field(repr=False, hash=False, compare=False)
203+
query: str | None = field(default=None, repr=False, hash=False, compare=False)
204+
205+
def __init__(self, value: "DocumentValue | DynamicTrait" = None):
206+
super().__init__(value)
207+
assert isinstance(self.document_value, Mapping)
208+
assert isinstance(self.document_value["method"], str)
209+
210+
code = self.document_value.get("code", 200)
211+
assert isinstance(code, int)
212+
object.__setattr__(self, "code", code)
213+
214+
uri = self.document_value["uri"]
215+
assert isinstance(uri, str)
216+
parts = uri.split("?", 1)
217+
218+
object.__setattr__(self, "path", PathPattern(parts[0]))
219+
object.__setattr__(self, "query", parts[1] if len(parts) == 2 else None)
220+
221+
@property
222+
def method(self) -> str:
223+
return self.document_value["method"] # type: ignore
224+
225+
226+
@dataclass(init=False, frozen=True)
227+
class HTTPErrorTrait(Trait, id=ShapeID("smithy.api#httpError")):
228+
def __post_init__(self):
229+
assert isinstance(self.document_value, int)
230+
231+
@property
232+
def code(self) -> int:
233+
return self.document_value # type: ignore
234+
235+
236+
@dataclass(init=False, frozen=True)
237+
class HTTPHeaderTrait(Trait, id=ShapeID("smithy.api#httpHeader")):
238+
def __post_init__(self):
239+
assert isinstance(self.document_value, str)
240+
241+
@property
242+
def key(self) -> str:
243+
return self.document_value # type: ignore
244+
245+
246+
@dataclass(init=False, frozen=True)
247+
class HTTPLabelTrait(Trait, id=ShapeID("smithy.api#httpLabel")):
248+
def __post_init__(self):
249+
assert self.document_value is None
250+
251+
252+
@dataclass(init=False, frozen=True)
253+
class HTTPPayloadTrait(Trait, id=ShapeID("smithy.api#httpPayload")):
254+
def __post_init__(self):
255+
assert self.document_value is None
256+
257+
258+
@dataclass(init=False, frozen=True)
259+
class HTTPPrefixHeadersTrait(Trait, id=ShapeID("smithy.api#httpPrefixHeaders")):
260+
def __post_init__(self):
261+
assert isinstance(self.document_value, str)
262+
263+
@property
264+
def prefix(self) -> str:
265+
return self.document_value # type: ignore
266+
267+
268+
@dataclass(init=False, frozen=True)
269+
class HTTPQueryTrait(Trait, id=ShapeID("smithy.api#httpQuery")):
270+
def __post_init__(self):
271+
assert isinstance(self.document_value, str)
272+
273+
@property
274+
def name(self) -> str:
275+
return self.document_value # type: ignore
276+
277+
278+
@dataclass(init=False, frozen=True)
279+
class HTTPQueryParamsTrait(Trait, id=ShapeID("smithy.api#httpQueryParams")):
280+
def __post_init__(self):
281+
assert self.document_value is None
282+
283+
284+
@dataclass(init=False, frozen=True)
285+
class HTTPResponseCodeTrait(Trait, id=ShapeID("smithy.api#httpResponseCode")):
286+
def __post_init__(self):
287+
assert self.document_value is None
288+
289+
290+
@dataclass(init=False, frozen=True)
291+
class HTTPChecksumRequiredTrait(Trait, id=ShapeID("smithy.api#httpChecksumRequired")):
292+
def __post_init__(self):
293+
assert self.document_value is None
294+
295+
296+
@dataclass(init=False, frozen=True)
297+
class EndpointTrait(Trait, id=ShapeID("smithy.api#endpoint")):
298+
def __post_init__(self):
299+
assert isinstance(self.document_value, str)
300+
301+
@property
302+
def host_prefix(self) -> str:
303+
return self.document_value["hostPrefix"] # type: ignore
304+
305+
306+
@dataclass(init=False, frozen=True)
307+
class HostLabelTrait(Trait, id=ShapeID("smithy.api#hostLabel")):
308+
def __post_init__(self):
309+
assert self.document_value is None

packages/smithy-core/src/smithy_core/types.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
22
# SPDX-License-Identifier: Apache-2.0
33
import json
4+
import re
45
from collections.abc import Mapping, Sequence
56
from datetime import datetime
67
from email.utils import format_datetime, parsedate_to_datetime
78
from enum import Enum
89
from typing import Any
10+
from dataclasses import dataclass
911

1012
from .exceptions import ExpectationNotMetException
1113
from .utils import (
@@ -16,6 +18,8 @@
1618
serialize_rfc3339,
1719
)
1820

21+
_GREEDY_LABEL_RE = re.compile(r"\{(\w+)\+\}")
22+
1923
type Document = (
2024
Mapping[str, "Document"] | Sequence["Document"] | str | int | float | bool | None
2125
)
@@ -111,3 +115,41 @@ def deserialize(self, value: str | float) -> datetime:
111115
return ensure_utc(parsedate_to_datetime(expect_type(str, value)))
112116
case TimestampFormat.DATE_TIME:
113117
return ensure_utc(datetime.fromisoformat(expect_type(str, value)))
118+
119+
120+
@dataclass(init=False, frozen=True)
121+
class PathPattern:
122+
"""A formattable URI path pattern.
123+
124+
The pattern may contain formattlable labels, which may be normal labels or greedy
125+
labels. Normal labels forbid path separators, greedy labels allow them.
126+
"""
127+
128+
pattern: str
129+
"""The path component of the URI which is a formattable string."""
130+
131+
greedy_labels: set[str]
132+
"""The pattern labels whose values may contain path separators."""
133+
134+
def __init__(self, pattern: str) -> None:
135+
object.__setattr__(self, "pattern", pattern)
136+
object.__setattr__(
137+
self, "greedy_labels", set(_GREEDY_LABEL_RE.findall(pattern))
138+
)
139+
140+
def format(self, *args: object, **kwargs: str) -> str:
141+
if args:
142+
raise ValueError("PathPattern formatting requires only keyword arguments.")
143+
144+
for key, value in kwargs.items():
145+
if "/" in value and key not in self.greedy_labels:
146+
raise ValueError(
147+
'Non-greedy labels must not contain path separators ("/").'
148+
)
149+
150+
result = self.pattern.replace("+}", "}").format(**kwargs)
151+
if "//" in result:
152+
raise ValueError(
153+
f'Path must not contain empty segments, but was "{result}".'
154+
)
155+
return result

packages/smithy-core/tests/unit/test_types.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import pytest
88

99
from smithy_core.exceptions import ExpectationNotMetException
10-
from smithy_core.types import JsonBlob, JsonString, TimestampFormat
10+
from smithy_core.types import JsonBlob, JsonString, TimestampFormat, PathPattern
1111

1212

1313
def test_json_string() -> None:
@@ -180,3 +180,42 @@ def test_invalid_timestamp_format_type_raises(
180180
):
181181
with pytest.raises(ExpectationNotMetException):
182182
format.deserialize(value)
183+
184+
185+
def test_path_pattern_without_labels():
186+
assert PathPattern("/foo/").format() == "/foo/"
187+
188+
189+
def test_path_pattern_with_normal_label():
190+
assert PathPattern("/{foo}/").format(foo="foo") == "/foo/"
191+
192+
193+
def test_path_pattern_with_greedy_label():
194+
assert PathPattern("/{foo+}/").format(foo="foo") == "/foo/"
195+
196+
197+
def test_path_pattern_greedy_label_allows_path_sep():
198+
assert PathPattern("/{foo+}/").format(foo="foo/bar") == "/foo/bar/"
199+
200+
201+
def test_path_pattern_normal_label_disallows_path_sep():
202+
with pytest.raises(ValueError):
203+
PathPattern("/{foo}").format(foo="foo/bar")
204+
205+
206+
@pytest.mark.parametrize(
207+
"greedy, value",
208+
[
209+
(False, ""),
210+
(True, ""),
211+
(True, "/"),
212+
(True, "/foo"),
213+
(True, "foo/"),
214+
(True, "/foo/"),
215+
(True, "foo//bar"),
216+
],
217+
)
218+
def test_path_pattern_disallows_empty_segments(greedy: bool, value: str):
219+
pattern = PathPattern("/{foo+}/" if greedy else "/{foo}/")
220+
with pytest.raises(ValueError):
221+
pattern.format(foo=value)

0 commit comments

Comments
 (0)