Skip to content

Commit 57c59c3

Browse files
committed
Improve support for edge cases + add support for techstacks AutoQuery DTOs
1 parent 5f8f2c3 commit 57c59c3

File tree

7 files changed

+2336
-57
lines changed

7 files changed

+2336
-57
lines changed

servicestack/clients.py

Lines changed: 101 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import requests
1010
from requests.exceptions import HTTPError
1111
from requests.models import Response
12-
from stringcase import camelcase, snakecase
12+
from stringcase import camelcase, snakecase, uppercase
1313

1414
from servicestack.dtos import *
1515
from servicestack.fields import *
@@ -29,17 +29,65 @@ def _dump(obj):
2929
print("")
3030

3131

32+
def _get_type_vars_map(cls: Type, type_map: Dict[Union[type, TypeVar], type] = {}):
33+
if hasattr(cls, '__orig_bases__'):
34+
for base_cls in cls.__orig_bases__:
35+
_get_type_vars_map(base_cls, type_map)
36+
generic_def = get_origin(cls)
37+
if generic_def is not None:
38+
generic_type_args = get_args(cls)
39+
i = 0
40+
for t in generic_def.__parameters__:
41+
type_map[t] = generic_type_args[i]
42+
i += 1
43+
return type_map
44+
45+
46+
def _dict_with_string_keys(d: dict):
47+
to = {}
48+
for k, v in d.items():
49+
to[f"{k}"] = v
50+
return to
51+
52+
53+
def has_type_vars(cls: Type):
54+
if cls is None:
55+
return None
56+
return isinstance(cls, TypeVar) or any(isinstance(x, TypeVar) for x in cls.__args__)
57+
58+
3259
def _resolve_response_type(request):
33-
if isinstance(request, IReturn):
34-
for cls in request.__orig_bases__:
35-
if hasattr(cls, '__args__'):
36-
candidate = cls.__args__[0]
37-
if type(candidate) == ForwardRef:
38-
return _resolve_forwardref(candidate, type(request))
39-
return candidate
40-
if isinstance(request, IReturnVoid):
41-
return type(None)
42-
return None
60+
t = type(request)
61+
62+
def resolve_response_type():
63+
if isinstance(request, IReturn):
64+
for cls in t.__orig_bases__:
65+
if get_origin(cls) == IReturn and hasattr(cls, '__args__'):
66+
candidate = cls.__args__[0]
67+
if type(candidate) == ForwardRef:
68+
return _resolve_forwardref(candidate, type(request))
69+
return candidate
70+
if isinstance(request, IReturnVoid):
71+
return type(None)
72+
return None
73+
74+
if hasattr(t, 'response_type'):
75+
ret = t.response_type()
76+
if has_type_vars(ret):
77+
# avoid reifying type vars if request type has concrete type return marker
78+
ret_candidate = resolve_response_type()
79+
if not has_type_vars(ret_candidate):
80+
return ret_candidate
81+
82+
type_map = _dict_with_string_keys(_get_type_vars_map(t))
83+
if isinstance(ret, TypeVar):
84+
return get_origin(ret).__class_getitem__(type_map[f"{ret}"])
85+
reified_args = [type_map[f"{x}"] for x in ret.__args__]
86+
reified_type = get_origin(ret).__class_getitem__(*reified_args)
87+
return reified_type
88+
return ret
89+
else:
90+
return resolve_response_type()
4391

4492

4593
def resolve_httpmethod(request):
@@ -64,6 +112,10 @@ def qsvalue(arg):
64112
if not arg:
65113
return ""
66114
arg_type = type(arg)
115+
if is_list(arg_type):
116+
return "[" + ','.join([qsvalue(x) for x in arg]) + "]"
117+
if is_dict(arg_type):
118+
return "{" + ','.join([k + ":" + qsvalue(v) for k, v in arg]) + "}"
67119
if arg_type is str:
68120
return quote_plus(arg)
69121
if arg_type is bytes or arg_type is bytearray:
@@ -199,6 +251,27 @@ def dict_get(name: str, obj: dict, case: Callable[[str], str] = None):
199251
return None
200252

201253

254+
def sanitize_name(s: str):
255+
return s.replace('_', '').upper()
256+
257+
258+
def enum_get(cls: Enum, key: Union[str, int]):
259+
if type(key) == int:
260+
return cls[key]
261+
try:
262+
return cls[key]
263+
except Exception as e:
264+
try:
265+
upper_snake = uppercase(snakecase(key))
266+
return cls[upper_snake]
267+
except Exception as e2:
268+
sanitize_key = sanitize_name(key)
269+
for member in cls.__members__.keys():
270+
if sanitize_key == sanitize_name(member):
271+
return cls[member]
272+
raise TypeError(f"{key} is not a member of {nameof(Enum)}")
273+
274+
202275
def _resolve_type(cls: Type, substitute_types: Dict[Type, type]):
203276
if substitute_types is None:
204277
return cls
@@ -211,16 +284,11 @@ def convert(into: Type, obj: Any, substitute_types: Dict[Type, type] = None):
211284
into = unwrap(into)
212285
into = _resolve_type(into, substitute_types)
213286
if Log.debug_enabled():
214-
Log.debug(f"convert({into}, {obj})")
287+
Log.debug(f"convert({into}, {substitute_types}, {obj})")
215288

216289
generic_def = get_origin(into)
217290
if generic_def is not None and is_dataclass(generic_def):
218-
reified_types = {}
219-
generic_type_args = get_args(into)
220-
i = 0
221-
for t in generic_def.__parameters__:
222-
reified_types[t] = generic_type_args[i]
223-
i += 1
291+
reified_types = _get_type_vars_map(into)
224292
return convert(generic_def, obj, reified_types)
225293

226294
if is_dataclass(into):
@@ -261,6 +329,13 @@ def convert(into: Type, obj: Any, substitute_types: Dict[Type, type] = None):
261329
except Exception as e:
262330
Log.error(f"into().deserialize(obj) {into}({obj})", e)
263331
raise e
332+
elif issubclass(into, Enum):
333+
try:
334+
return enum_get(into, obj)
335+
except Exception as e:
336+
print(into, type(into), obj, type(obj))
337+
Log.error(f"Enum into[obj] {into}[{obj}]", e)
338+
raise e
264339
else:
265340
try:
266341
return into(obj)
@@ -318,7 +393,8 @@ def exec(self):
318393
# if "ss-tok" in self.session.cookies:
319394
# ss_tok = self.session.cookies["ss-tok"]
320395
# print('ss_tok', inspect_jwt(ss_tok))
321-
Log.debug(f"{using}.request({self.method}): url={self.url}, headers={self.headers}, data={self.body_string}")
396+
Log.debug(
397+
f"{using}.request({self.method}): url={self.url}, headers={self.headers}, data={self.body_string}")
322398

323399
response: Optional[Response] = None
324400
if has_request_body(self.method):
@@ -409,10 +485,12 @@ def _get_cookie_value(self, name: str) -> Optional[str]:
409485
return None
410486

411487
@property
412-
def token_cookie(self): return self._get_cookie_value(SS_TOKEN_COOKIE)
488+
def token_cookie(self):
489+
return self._get_cookie_value(SS_TOKEN_COOKIE)
413490

414491
@property
415-
def refresh_token_cookie(self): return self._get_cookie_value(SS_REFRESH_TOKEN_COOKIE)
492+
def refresh_token_cookie(self):
493+
return self._get_cookie_value(SS_REFRESH_TOKEN_COOKIE)
416494

417495
def create_url_from_dto(self, method: str, request: Any):
418496
url = urljoin(self.reply_base_url, nameof(request))
@@ -467,7 +545,8 @@ def put_url(self, path: str, body: Any = None, response_as: Type = None, args: d
467545
def patch_url(self, path: str, body: Any = None, response_as: Type = None, args: dict[str, Any] = None):
468546
return self.send_url(path, "PATCH", response_as, body, args)
469547

470-
def send_url(self, path: str, method: str = None, response_as: Type = None, body: Any = None, args: dict[str, Any] = None):
548+
def send_url(self, path: str, method: str = None, response_as: Type = None, body: Any = None,
549+
args: dict[str, Any] = None):
471550

472551
if body and not response_as:
473552
response_as = _resolve_response_type(body)

servicestack/dtos.py

Lines changed: 24 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -123,48 +123,54 @@ class QueryBase:
123123

124124
@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE)
125125
@dataclass
126-
class QueryDb(Generic[T], QueryBase):
127-
pass
126+
class QueryResponse(Generic[T]):
127+
offset: int = None
128+
total: int = None
129+
results: List[T] = None
130+
meta: Optional[Dict[str, str]] = None
131+
response_status: Optional[ResponseStatus] = None
128132

129133

130134
@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE)
131135
@dataclass
132-
class QueryDb1(Generic[T], QueryBase):
133-
pass
136+
class QueryDb(Generic[T], QueryBase, IReturn[QueryResponse[T]]):
137+
@staticmethod
138+
def response_type(): return QueryResponse[T]
134139

135140

136141
@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE)
137142
@dataclass
138-
class QueryDb2(Generic[From, Into], QueryBase):
139-
pass
143+
class QueryDb1(Generic[T], QueryBase, IReturn[QueryResponse[T]]):
144+
@staticmethod
145+
def response_type(): return QueryResponse[T]
140146

141147

142148
@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE)
143149
@dataclass
144-
class QueryData(Generic[T], QueryBase):
145-
pass
150+
class QueryDb2(Generic[From, Into], QueryBase, IReturn[QueryResponse[Into]]):
151+
@staticmethod
152+
def response_type(): return QueryResponse[Into]
146153

147154

148155
@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE)
149156
@dataclass
150-
class QueryData1(Generic[From, Into], QueryBase):
151-
pass
157+
class QueryData(Generic[T], QueryBase, IReturn[QueryResponse[T]]):
158+
@staticmethod
159+
def response_type(): return QueryResponse[T]
152160

153161

154162
@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE)
155163
@dataclass
156-
class QueryData2(Generic[From, Into], QueryBase):
157-
pass
164+
class QueryData1(Generic[From, Into], QueryBase, IReturn[QueryResponse[Into]]):
165+
@staticmethod
166+
def response_type(): return QueryResponse[Into]
158167

159168

160169
@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE)
161170
@dataclass
162-
class QueryResponse(Generic[T]):
163-
offset: int = None
164-
total: int = None
165-
results: List[T] = None
166-
meta: Optional[Dict[str, str]] = None
167-
response_status: Optional[ResponseStatus] = None
171+
class QueryData2(Generic[From, Into], QueryBase, IReturn[QueryResponse[Into]]):
172+
@staticmethod
173+
def response_type(): return QueryResponse[Into]
168174

169175

170176
@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE)

servicestack/utils.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,26 @@ def from_datetime(json_date: str):
163163
else:
164164
return _MAX_UTC_DATE
165165

166-
return datetime.fromisoformat(json_date)
166+
# need to reduce to 6f precision and remove trailing Z
167+
has_sec_fraction = index_of(json_date, '.') >= 0
168+
is_utc = json_date.endswith('Z')
169+
if is_utc:
170+
json_date = json_date[0:-1]
171+
if has_sec_fraction:
172+
sec_fraction = last_right_part(json_date, '.')
173+
tz = ''
174+
if '+' in sec_fraction:
175+
tz = '+' + right_part(sec_fraction, '+')
176+
sec_fraction = left_part(sec_fraction, '+')
177+
elif '-' in sec_fraction:
178+
sec_fraction = left_part(sec_fraction, '-')
179+
if len(sec_fraction) > 6:
180+
json_date = last_left_part(json_date, '.') + '.' + sec_fraction[0:6] + tz
181+
182+
if is_utc:
183+
return datetime.fromisoformat(json_date).replace(tzinfo=timezone.utc)
184+
else:
185+
return datetime.fromisoformat(json_date)
167186

168187

169188
def to_bytearray(value: Optional[bytes]):

tests/config.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,16 @@
33
# TEST_URL = "https://localhost:5001"
44
# TEST_URL = "http://localhost:5000"
55
TEST_URL = "http://test.servicestack.net"
6-
6+
TECHSTACKS_URL = "https://techstacks.io"
77

88
def create_test_client():
99
return JsonServiceClient(TEST_URL)
1010

1111

12+
def create_techstacks_client():
13+
return JsonServiceClient(TECHSTACKS_URL)
14+
15+
1216
def clear_session(client: JsonServiceClient):
1317
client.post(Authenticate(provider="logout"))
1418

0 commit comments

Comments
 (0)