ul-api-utils 9.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. example/__init__.py +0 -0
  2. example/conf.py +35 -0
  3. example/main.py +24 -0
  4. example/models/__init__.py +0 -0
  5. example/permissions.py +6 -0
  6. example/pure_flask_example.py +65 -0
  7. example/rate_limit_load.py +10 -0
  8. example/redis_repository.py +22 -0
  9. example/routes/__init__.py +0 -0
  10. example/routes/api_some.py +335 -0
  11. example/sockets/__init__.py +0 -0
  12. example/sockets/on_connect.py +16 -0
  13. example/sockets/on_disconnect.py +14 -0
  14. example/sockets/on_json.py +10 -0
  15. example/sockets/on_message.py +13 -0
  16. example/sockets/on_open.py +16 -0
  17. example/workers/__init__.py +0 -0
  18. example/workers/worker.py +28 -0
  19. ul_api_utils/__init__.py +0 -0
  20. ul_api_utils/access/__init__.py +122 -0
  21. ul_api_utils/api_resource/__init__.py +0 -0
  22. ul_api_utils/api_resource/api_request.py +105 -0
  23. ul_api_utils/api_resource/api_resource.py +414 -0
  24. ul_api_utils/api_resource/api_resource_config.py +20 -0
  25. ul_api_utils/api_resource/api_resource_error_handling.py +21 -0
  26. ul_api_utils/api_resource/api_resource_fn_typing.py +356 -0
  27. ul_api_utils/api_resource/api_resource_type.py +16 -0
  28. ul_api_utils/api_resource/api_response.py +300 -0
  29. ul_api_utils/api_resource/api_response_db.py +26 -0
  30. ul_api_utils/api_resource/api_response_payload_alias.py +25 -0
  31. ul_api_utils/api_resource/db_types.py +9 -0
  32. ul_api_utils/api_resource/signature_check.py +41 -0
  33. ul_api_utils/commands/__init__.py +0 -0
  34. ul_api_utils/commands/cmd_enc_keys.py +172 -0
  35. ul_api_utils/commands/cmd_gen_api_user_token.py +77 -0
  36. ul_api_utils/commands/cmd_gen_new_api_user.py +106 -0
  37. ul_api_utils/commands/cmd_generate_api_docs.py +181 -0
  38. ul_api_utils/commands/cmd_start.py +110 -0
  39. ul_api_utils/commands/cmd_worker_start.py +76 -0
  40. ul_api_utils/commands/start/__init__.py +0 -0
  41. ul_api_utils/commands/start/gunicorn.conf.local.py +0 -0
  42. ul_api_utils/commands/start/gunicorn.conf.py +26 -0
  43. ul_api_utils/commands/start/wsgi.py +22 -0
  44. ul_api_utils/conf/ul-debugger-main.js +1 -0
  45. ul_api_utils/conf/ul-debugger-ui.js +1 -0
  46. ul_api_utils/conf.py +70 -0
  47. ul_api_utils/const.py +78 -0
  48. ul_api_utils/debug/__init__.py +0 -0
  49. ul_api_utils/debug/debugger.py +119 -0
  50. ul_api_utils/debug/malloc.py +93 -0
  51. ul_api_utils/debug/stat.py +444 -0
  52. ul_api_utils/encrypt/__init__.py +0 -0
  53. ul_api_utils/encrypt/encrypt_decrypt_abstract.py +15 -0
  54. ul_api_utils/encrypt/encrypt_decrypt_aes_xtea.py +59 -0
  55. ul_api_utils/errors.py +200 -0
  56. ul_api_utils/internal_api/__init__.py +0 -0
  57. ul_api_utils/internal_api/__tests__/__init__.py +0 -0
  58. ul_api_utils/internal_api/__tests__/internal_api.py +29 -0
  59. ul_api_utils/internal_api/__tests__/internal_api_content_type.py +22 -0
  60. ul_api_utils/internal_api/internal_api.py +369 -0
  61. ul_api_utils/internal_api/internal_api_check_context.py +42 -0
  62. ul_api_utils/internal_api/internal_api_error.py +17 -0
  63. ul_api_utils/internal_api/internal_api_response.py +296 -0
  64. ul_api_utils/main.py +29 -0
  65. ul_api_utils/modules/__init__.py +0 -0
  66. ul_api_utils/modules/__tests__/__init__.py +0 -0
  67. ul_api_utils/modules/__tests__/test_api_sdk_jwt.py +195 -0
  68. ul_api_utils/modules/api_sdk.py +555 -0
  69. ul_api_utils/modules/api_sdk_config.py +63 -0
  70. ul_api_utils/modules/api_sdk_jwt.py +377 -0
  71. ul_api_utils/modules/intermediate_state.py +34 -0
  72. ul_api_utils/modules/worker_context.py +35 -0
  73. ul_api_utils/modules/worker_sdk.py +109 -0
  74. ul_api_utils/modules/worker_sdk_config.py +13 -0
  75. ul_api_utils/py.typed +0 -0
  76. ul_api_utils/resources/__init__.py +0 -0
  77. ul_api_utils/resources/caching.py +196 -0
  78. ul_api_utils/resources/debugger_scripts.py +97 -0
  79. ul_api_utils/resources/health_check/__init__.py +0 -0
  80. ul_api_utils/resources/health_check/const.py +2 -0
  81. ul_api_utils/resources/health_check/health_check.py +439 -0
  82. ul_api_utils/resources/health_check/health_check_template.py +64 -0
  83. ul_api_utils/resources/health_check/resource.py +97 -0
  84. ul_api_utils/resources/not_implemented.py +25 -0
  85. ul_api_utils/resources/permissions.py +29 -0
  86. ul_api_utils/resources/rate_limitter.py +84 -0
  87. ul_api_utils/resources/socketio.py +55 -0
  88. ul_api_utils/resources/swagger.py +119 -0
  89. ul_api_utils/resources/web_forms/__init__.py +0 -0
  90. ul_api_utils/resources/web_forms/custom_fields/__init__.py +0 -0
  91. ul_api_utils/resources/web_forms/custom_fields/custom_checkbox_select.py +5 -0
  92. ul_api_utils/resources/web_forms/custom_widgets/__init__.py +0 -0
  93. ul_api_utils/resources/web_forms/custom_widgets/custom_select_widget.py +86 -0
  94. ul_api_utils/resources/web_forms/custom_widgets/custom_text_input_widget.py +42 -0
  95. ul_api_utils/resources/web_forms/uni_form.py +75 -0
  96. ul_api_utils/sentry.py +52 -0
  97. ul_api_utils/utils/__init__.py +0 -0
  98. ul_api_utils/utils/__tests__/__init__.py +0 -0
  99. ul_api_utils/utils/__tests__/api_path_version.py +16 -0
  100. ul_api_utils/utils/__tests__/unwrap_typing.py +67 -0
  101. ul_api_utils/utils/api_encoding.py +51 -0
  102. ul_api_utils/utils/api_format.py +61 -0
  103. ul_api_utils/utils/api_method.py +55 -0
  104. ul_api_utils/utils/api_pagination.py +58 -0
  105. ul_api_utils/utils/api_path_version.py +60 -0
  106. ul_api_utils/utils/api_request_info.py +6 -0
  107. ul_api_utils/utils/avro.py +131 -0
  108. ul_api_utils/utils/broker_topics_message_count.py +47 -0
  109. ul_api_utils/utils/cached_per_request.py +23 -0
  110. ul_api_utils/utils/colors.py +31 -0
  111. ul_api_utils/utils/constants.py +7 -0
  112. ul_api_utils/utils/decode_base64.py +9 -0
  113. ul_api_utils/utils/deprecated.py +19 -0
  114. ul_api_utils/utils/flags.py +29 -0
  115. ul_api_utils/utils/flask_swagger_generator/__init__.py +0 -0
  116. ul_api_utils/utils/flask_swagger_generator/conf.py +4 -0
  117. ul_api_utils/utils/flask_swagger_generator/exceptions.py +7 -0
  118. ul_api_utils/utils/flask_swagger_generator/specifiers/__init__.py +0 -0
  119. ul_api_utils/utils/flask_swagger_generator/specifiers/swagger_models.py +57 -0
  120. ul_api_utils/utils/flask_swagger_generator/specifiers/swagger_specifier.py +48 -0
  121. ul_api_utils/utils/flask_swagger_generator/specifiers/swagger_three_specifier.py +777 -0
  122. ul_api_utils/utils/flask_swagger_generator/specifiers/swagger_version.py +40 -0
  123. ul_api_utils/utils/flask_swagger_generator/utils/__init__.py +0 -0
  124. ul_api_utils/utils/flask_swagger_generator/utils/input_type.py +77 -0
  125. ul_api_utils/utils/flask_swagger_generator/utils/parameter_type.py +51 -0
  126. ul_api_utils/utils/flask_swagger_generator/utils/replace_in_dict.py +18 -0
  127. ul_api_utils/utils/flask_swagger_generator/utils/request_type.py +52 -0
  128. ul_api_utils/utils/flask_swagger_generator/utils/schema_type.py +15 -0
  129. ul_api_utils/utils/flask_swagger_generator/utils/security_type.py +39 -0
  130. ul_api_utils/utils/imports.py +16 -0
  131. ul_api_utils/utils/instance_checks.py +16 -0
  132. ul_api_utils/utils/jinja/__init__.py +0 -0
  133. ul_api_utils/utils/jinja/t_url_for.py +19 -0
  134. ul_api_utils/utils/jinja/to_pretty_json.py +11 -0
  135. ul_api_utils/utils/json_encoder.py +126 -0
  136. ul_api_utils/utils/load_modules.py +15 -0
  137. ul_api_utils/utils/memory_db/__init__.py +0 -0
  138. ul_api_utils/utils/memory_db/__tests__/__init__.py +0 -0
  139. ul_api_utils/utils/memory_db/errors.py +8 -0
  140. ul_api_utils/utils/memory_db/repository.py +102 -0
  141. ul_api_utils/utils/token_check.py +14 -0
  142. ul_api_utils/utils/token_check_through_request.py +16 -0
  143. ul_api_utils/utils/unwrap_typing.py +117 -0
  144. ul_api_utils/utils/uuid_converter.py +22 -0
  145. ul_api_utils/validators/__init__.py +0 -0
  146. ul_api_utils/validators/__tests__/__init__.py +0 -0
  147. ul_api_utils/validators/__tests__/test_custom_fields.py +32 -0
  148. ul_api_utils/validators/custom_fields.py +66 -0
  149. ul_api_utils/validators/validate_empty_object.py +10 -0
  150. ul_api_utils/validators/validate_uuid.py +11 -0
  151. ul_api_utils-9.3.0.dist-info/LICENSE +21 -0
  152. ul_api_utils-9.3.0.dist-info/METADATA +279 -0
  153. ul_api_utils-9.3.0.dist-info/RECORD +156 -0
  154. ul_api_utils-9.3.0.dist-info/WHEEL +5 -0
  155. ul_api_utils-9.3.0.dist-info/entry_points.txt +2 -0
  156. ul_api_utils-9.3.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,356 @@
1
+ import inspect
2
+ from typing import NamedTuple, Any, Callable, Optional, List, Dict, Type, Tuple, TYPE_CHECKING, Union, get_origin, get_args
3
+
4
+ from flask import request
5
+ from pydantic import BaseModel, ValidationError, validate_call, TypeAdapter, RootModel
6
+ from pydantic.v1.utils import deep_update
7
+ from pydantic_core import ErrorDetails
8
+
9
+ from ul_api_utils.api_resource.api_request import ApiRequestQuery
10
+ from ul_api_utils.api_resource.api_resource_type import ApiResourceType
11
+ from ul_api_utils.api_resource.api_response import HtmlApiResponse, JsonApiResponse, FileApiResponse, \
12
+ RedirectApiResponse, ApiResponse, \
13
+ JsonApiResponsePayload, RootJsonApiResponsePayload, ProxyJsonApiResponse, AnyJsonApiResponse, \
14
+ EmptyJsonApiResponse, TPayloadTotalUnion, RootJsonApiResponse
15
+ from ul_api_utils.api_resource.signature_check import get_typing, set_model_dictable, set_model
16
+ from ul_api_utils.errors import ValidationListApiError, ResourceRuntimeApiError, InvalidContentTypeError
17
+ from ul_api_utils.utils.api_format import ApiFormat
18
+ from ul_api_utils.utils.api_method import ApiMethod
19
+ from ul_api_utils.utils.json_encoder import to_dict
20
+ from ul_api_utils.utils.unwrap_typing import UnwrappedOptionalObjOrListOfObj
21
+
22
+ if TYPE_CHECKING:
23
+ from ul_api_utils.api_resource.api_resource import ApiResource
24
+
25
+ FN_SYSTEM_PROPS = {"api_resource", "query", "body", "return", "body_validation_error", "query_validation_error"}
26
+
27
+
28
+ def _is_complex_type(annotation: Any) -> bool:
29
+ origin = get_origin(annotation)
30
+
31
+ # Optional[type_] is typing.Union
32
+ if origin is Union:
33
+ return any(_is_complex_type(arg) for arg in get_args(annotation))
34
+
35
+ return origin in (list, dict, tuple, set)
36
+
37
+ def _patch_errors(dest_errors: List[Dict[str, str]], errors: List[ErrorDetails], kind: str) -> List[Dict[str, str]]:
38
+ for error in errors:
39
+ dest_errors.append({
40
+ "error_type": kind,
41
+ "error_message": error['msg'],
42
+ "error_location": error["loc"], # type: ignore
43
+ "error_kind": error["type"],
44
+ "error_input": error["input"],
45
+ })
46
+ return dest_errors
47
+
48
+
49
+ @validate_call()
50
+ def _body_list(root: List[Dict[str, Any]]) -> List[Any]: # only for pydantic validation
51
+ return root
52
+
53
+
54
+ class ApiResourceFnTyping(NamedTuple):
55
+ fn: Callable[..., ApiResponse]
56
+
57
+ request_body_many: bool
58
+ request_body_optional: bool
59
+ response_payload_many: bool
60
+
61
+ api_resource_type: ApiResourceType
62
+ signatures_typing: List[Tuple[str, Type[Any]]]
63
+
64
+ body_typing: Optional[Type[BaseModel]] # none if it is not specified
65
+ has_body_validation_error: bool
66
+ query_typing: Optional[Type[ApiRequestQuery]] # none if it is not specified
67
+ has_query_validation_error: bool
68
+ return_typing: Optional[Type[ApiResponse]] # NOT NONE for api
69
+ return_payload_typing: Optional[Type[JsonApiResponsePayload] | Type[RootJsonApiResponsePayload[Any]]] # NOT NONE for api
70
+
71
+ def get_return_schema(self) -> Type[BaseModel]:
72
+ inner_type = self.return_payload_typing
73
+ if self.response_payload_many:
74
+ inner_type = List[inner_type] # type: ignore
75
+ return self.return_typing._internal_use__mk_schema(inner_type) # type: ignore
76
+
77
+ def get_body_schema(self) -> Optional[Type[BaseModel]]:
78
+ if self.body_typing is None:
79
+ return None
80
+
81
+ body_typing = self.body_typing
82
+
83
+ if self.request_body_optional:
84
+ if self.request_body_many:
85
+ class BodyTypingOptList(RootModel[List[body_typing] | None]): # type: ignore
86
+ pass
87
+
88
+ return BodyTypingOptList
89
+
90
+ class BodyTypingOpt(RootModel[Optional[body_typing]]): # type: ignore
91
+ pass
92
+ return BodyTypingOpt
93
+
94
+ if self.request_body_many:
95
+ class BodyTypingList(RootModel[List[body_typing]]): # type: ignore
96
+ pass
97
+
98
+ return BodyTypingList
99
+ return body_typing
100
+
101
+ def runtime_validate_api_proxy_payload(self, response: Dict[str, Any], *, quick: bool) -> None:
102
+ assert isinstance(response, dict)
103
+
104
+ def runtime_validate_api_response_payload(self, payload: Any, total_count: Optional[int], *, quick: bool) -> TPayloadTotalUnion: # type: ignore
105
+ quick = quick or self.return_payload_typing is None
106
+
107
+ if self.return_typing == AnyJsonApiResponse:
108
+ return payload, total_count # type: ignore
109
+
110
+ if self.response_payload_many:
111
+ if not (isinstance(total_count, int) and total_count >= 0):
112
+ raise ResourceRuntimeApiError(f'total_count must be int >= 0. {type(total_count).__name__} was given. Error value: {total_count}')
113
+ new_payload = []
114
+ for o in payload:
115
+ r = to_dict(o) if quick else set_model_dictable(self.return_payload_typing, o) # type: ignore
116
+ if r is None:
117
+ raise ResourceRuntimeApiError(f'invalid type of object. {type(o).__name__} was given')
118
+ new_payload.append(r)
119
+ return new_payload, total_count
120
+
121
+ if payload is None: # only for case when payload must be single object
122
+ return None, None
123
+
124
+ new_payload = to_dict(payload) if quick else set_model_dictable(self.return_payload_typing, payload) # type: ignore
125
+ if new_payload is None:
126
+ raise ResourceRuntimeApiError(f'invalid type of object. {type(payload).__name__} was given')
127
+ return new_payload, None
128
+
129
+ def _get_body(self) -> Optional[Any]:
130
+ if self.api_resource_type == ApiResourceType.WEB:
131
+ res_dict: Dict[str, Any] = {}
132
+ for key, value in request.form.to_dict(flat=False).items():
133
+ if '[]' in key:
134
+ key = key.replace('[]', '')
135
+ res_dict[key] = value
136
+ elif '[' in key:
137
+ key, *dict_keys = key.split('[')
138
+ dict_keys = [dict_key[:-1] for dict_key in dict_keys]
139
+ tree_dict: Dict[str, Any] = {}
140
+ for iter, dict_key in enumerate(reversed(dict_keys)):
141
+ if iter == 0:
142
+ tree_dict = {dict_key: value[0]}
143
+ else:
144
+ tree_dict = {dict_key: tree_dict}
145
+ res_dict.setdefault(key, dict())
146
+ res_dict[key] = deep_update(res_dict[key], tree_dict)
147
+ else:
148
+ res_dict[key] = value[0]
149
+ return res_dict
150
+ if self.api_resource_type not in (ApiResourceType.API, ApiResourceType.FILE):
151
+ return None
152
+ if not request.is_json:
153
+ body_data: Optional[bytes] = request.get_data()
154
+ api_format = ApiFormat.from_mime(request.mimetype)
155
+ if api_format is None:
156
+ raise InvalidContentTypeError(f"Failed to decode JSON object: invalid content type '{request.mimetype}'")
157
+ return api_format.parse_bytes(body_data) if body_data else None
158
+ else:
159
+ return request.json # TODO: make this from ApiFormat
160
+
161
+ def _runtime_validate_body(self, method: ApiMethod, kwargs: Dict[str, Any], errors: List[Dict[str, str]]) -> Tuple[Dict[str, Any], List[Dict[str, str]]]:
162
+ if self.request_body_optional:
163
+ kwargs['body'] = None
164
+ if method in {ApiMethod.GET, ApiMethod.OPTIONS} or self.body_typing is None:
165
+ return kwargs, errors
166
+ body = self._get_body()
167
+ try:
168
+ expected_typing: Type[BaseModel | List[BaseModel]] = List[self.body_typing] if self.request_body_many else self.body_typing # type: ignore
169
+ kwargs['body'] = TypeAdapter(expected_typing).validate_python(body)
170
+ except ValidationError as ve:
171
+ if self.has_body_validation_error:
172
+ kwargs['body_validation_error'] = ve
173
+ else:
174
+ _patch_errors(errors, ve.errors(), "body-validation-error")
175
+ return kwargs, errors
176
+
177
+ def _runtime_validate_query(self, kwargs: Dict[str, Any], errors: List[Dict[str, str]]) -> Tuple[Dict[str, Any], List[Dict[str, str]]]:
178
+ if self.query_typing is None:
179
+ return kwargs, errors
180
+ try:
181
+ kwargs["query"] = set_model(self.query_typing, {
182
+ **request.args.to_dict(),
183
+ **{
184
+ key: value for key, value in request.args.to_dict(flat=False).items()
185
+ if key in self.query_typing.model_fields
186
+ and _is_complex_type(self.query_typing.model_fields[key].annotation)
187
+ },
188
+ })
189
+ except ValidationError as ve:
190
+ if self.has_query_validation_error:
191
+ kwargs['query_validation_error'] = ve
192
+ else:
193
+ _patch_errors(errors, ve.errors(), "query-validation-error")
194
+ return kwargs, errors
195
+
196
+ def _runtime_validate_signature(self, kwargs: Dict[str, Any], errors: List[Dict[str, str]]) -> Tuple[Dict[str, Any], List[Dict[str, str]]]:
197
+ loc_err = []
198
+ for name, type_ in self.signatures_typing:
199
+ try:
200
+ kwargs[name] = TypeAdapter(type_).validate_python(kwargs.get(name))
201
+ except ValidationError as e:
202
+ err = e.errors()[0]
203
+ err["loc"] = [name] # type: ignore
204
+ loc_err.append(err)
205
+ _patch_errors(errors, loc_err, "path-params-validation-error")
206
+ return kwargs, errors
207
+
208
+ def runtime_validate_request_input(self, method: ApiMethod, fn_kwargs: Dict[str, Any]) -> Dict[str, Any]:
209
+ errors: List[Dict[str, str]] = []
210
+ fn_kwargs, errors = self._runtime_validate_signature(fn_kwargs, errors)
211
+ fn_kwargs, errors = self._runtime_validate_query(fn_kwargs, errors)
212
+ fn_kwargs, errors = self._runtime_validate_body(method, fn_kwargs, errors)
213
+ if len(errors) > 0:
214
+ raise ValidationListApiError(errors)
215
+ return fn_kwargs
216
+
217
+ @classmethod
218
+ def _parse_body_typing(cls, fn: Callable[['ApiResource'], ApiResponse]) -> Tuple[bool, bool, bool, Optional[Type[BaseModel]]]:
219
+ request_many = False
220
+ body_typing = fn.__annotations__.get('body', None)
221
+ body_validation_error_typing = fn.__annotations__.get('body_validation_error', None)
222
+ body_is_optional = False
223
+
224
+ if body_typing is not None:
225
+ body_typing_parsed = UnwrappedOptionalObjOrListOfObj.parse(body_typing, BaseModel)
226
+ tn = BaseModel.__name__
227
+ assert body_typing_parsed is not None, \
228
+ f'body_typing is invalid. must be Union[Type[{tn}], Optional[Type[{tn}]], List[Type[{tn}]], Optional[List[Type[{tn}]]]], {body_typing} was given'
229
+ body_is_optional = body_typing_parsed.optional
230
+ request_many = body_typing_parsed.many
231
+ body_typing = body_typing_parsed.value_type
232
+
233
+ if body_validation_error_typing:
234
+ assert body_typing is not None
235
+ assert body_is_optional, 'query typing must be optional'
236
+ body_validation_error_typing_parsed = UnwrappedOptionalObjOrListOfObj.parse(body_validation_error_typing, ValidationError)
237
+ assert body_validation_error_typing_parsed is not None and body_validation_error_typing_parsed.optional and not body_validation_error_typing_parsed.many, \
238
+ f'query_typing is invalid. must be Optional[ValidationError], {body_validation_error_typing} was given'
239
+ return request_many, body_is_optional, body_validation_error_typing is not None, body_typing
240
+
241
+ @classmethod
242
+ def _parse_return_typing(
243
+ cls,
244
+ api_resource_type: 'ApiResourceType',
245
+ fn: Callable[['ApiResource'], ApiResponse],
246
+ ) -> Tuple[bool, Optional[Type[JsonApiResponsePayload] | Type[RootJsonApiResponsePayload[Any]]], Optional[Type[ApiResponse]]]:
247
+ response_many = False
248
+ return_typing = fn.__annotations__.get('return', None)
249
+ ret = get_typing(return_typing)
250
+ if len(ret) > 1:
251
+ assert len(ret) == 2, f'invalid generic arguments. must be 1. {len(ret) - 1} was given'
252
+ return_typing, return_payload_typing = ret
253
+ else:
254
+ return_typing, return_payload_typing = ret[0], None
255
+
256
+ assert inspect.isclass(return_typing), f'{fn.__name__} :: invalid response typing. {return_typing} was given'
257
+
258
+ if return_typing != RedirectApiResponse:
259
+ if api_resource_type == ApiResourceType.API:
260
+ if return_payload_typing is not None:
261
+ ret_payload_res = get_typing(return_payload_typing)
262
+ if len(ret_payload_res) > 1:
263
+ return_payload_typing = ret_payload_res[1]
264
+ if ret_payload_res[0] != list: # noqa: E721
265
+ raise TypeError(f'{fn.__name__} :: invalid response payload type wrapper. only List is supported')
266
+ response_many = True
267
+
268
+ assert return_typing is not None, \
269
+ f'{fn.__name__} :: invalid response typing. it must be not None. {return_typing}[{return_payload_typing}] was given'
270
+
271
+ if return_typing is EmptyJsonApiResponse:
272
+ assert return_payload_typing is None, f'{fn.__name__} :: invalid response payload typing. payload must be None. {return_payload_typing.__name__} was given'
273
+
274
+ elif return_typing is RootJsonApiResponse:
275
+ assert return_payload_typing is not None and issubclass(return_payload_typing, (JsonApiResponsePayload, RootJsonApiResponsePayload)), \
276
+ f'{fn.__name__} :: invalid response payload typing. payload must be subclass of (JsonApiResponsePayload, RootJsonApiResponsePayload). ' \
277
+ f'{return_payload_typing.__name__ if return_payload_typing is not None else "None"} was given'
278
+
279
+ elif return_typing is AnyJsonApiResponse:
280
+ assert return_payload_typing is None, f'{fn.__name__} :: invalid response payload typing. payload must be None. {return_payload_typing.__name__} was given'
281
+
282
+ elif issubclass(return_typing, JsonApiResponse):
283
+ assert return_payload_typing is not None and issubclass(return_payload_typing, (JsonApiResponsePayload, RootJsonApiResponsePayload)), \
284
+ f'{fn.__name__} :: invalid response payload typing. payload must be subclass of (JsonApiResponsePayload, RootJsonApiResponsePayload). ' \
285
+ f'{return_payload_typing.__name__ if return_payload_typing is not None else "None"} was given'
286
+
287
+ elif issubclass(return_typing, ProxyJsonApiResponse):
288
+ assert return_payload_typing is not None and issubclass(return_payload_typing, (JsonApiResponsePayload, RootJsonApiResponsePayload)), \
289
+ f'{fn.__name__} :: invalid response payload typing. payload must be subclass of (JsonApiResponsePayload, RootJsonApiResponsePayload). ' \
290
+ f'{return_payload_typing.__name__ if return_payload_typing is not None else "None"} was given'
291
+
292
+ else:
293
+ raise TypeError(f'{fn.__name__} :: invalid response typing. {return_typing.__name__} was given')
294
+
295
+ elif api_resource_type is ApiResourceType.FILE:
296
+ assert return_typing is not None and issubclass(return_typing, FileApiResponse), \
297
+ f'{fn.__name__} :: invalid response typing. it must be subclass of HtmlApiResponse. {return_typing.__name__} was given'
298
+
299
+ elif api_resource_type is ApiResourceType.WEB:
300
+ assert return_typing is not None and issubclass(return_typing, HtmlApiResponse), \
301
+ f'{fn.__name__} :: invalid response typing. it must be subclass of HtmlApiResponse. {return_typing.__name__} was given'
302
+
303
+ return response_many, return_payload_typing, return_typing
304
+
305
+ @classmethod
306
+ def _parse_query_typing(cls, fn: Callable[['ApiResource'], ApiResponse]) -> Tuple[bool, Optional[Type[ApiRequestQuery]]]:
307
+ query_typing = fn.__annotations__.get('query', None)
308
+ query_validation_error_typing = fn.__annotations__.get('query_validation_error', None)
309
+ query_is_optional = False
310
+ if query_typing is not None:
311
+ query_typing_parsed = UnwrappedOptionalObjOrListOfObj.parse(query_typing, ApiRequestQuery)
312
+ tn = ApiRequestQuery.__name__
313
+ assert query_typing_parsed is not None and not query_typing_parsed.many, \
314
+ f'query_typing is invalid. must be Union[Type[{tn}], Optional[Type[{tn}]]], {query_typing} was given'
315
+ query_is_optional = query_typing_parsed.optional
316
+
317
+ if query_validation_error_typing:
318
+ assert query_typing is not None
319
+ assert query_is_optional, 'query typing must be optional'
320
+ query_err_typing_parsed = UnwrappedOptionalObjOrListOfObj.parse(query_validation_error_typing, ValidationError)
321
+ assert query_err_typing_parsed is not None and query_err_typing_parsed.optional and not query_err_typing_parsed.many, \
322
+ f'query_typing is invalid. must be Optional[ValidationError], {query_validation_error_typing} was given'
323
+ return query_validation_error_typing is not None, query_typing
324
+
325
+ @classmethod
326
+ def parse_fn(cls, api_resource_type: 'ApiResourceType', methods: List[ApiMethod], fn: Callable[['ApiResource'], ApiResponse]) -> 'ApiResourceFnTyping':
327
+ api_resource_typing = fn.__annotations__.get('api_resource', None)
328
+ if api_resource_typing is None or api_resource_typing.__name__ != 'ApiResource':
329
+ raise TypeError(f'{fn.__name__} :: invalid api_resource typing. must be ApiResource. {api_resource_typing} was given')
330
+
331
+ request_many, body_is_optional, has_body_validation_error, body_typing = cls._parse_body_typing(fn)
332
+ has_query_validation_error, query_typing = cls._parse_query_typing(fn)
333
+ response_many, return_payload_typing, return_typing = cls._parse_return_typing(api_resource_type, fn)
334
+
335
+ for method in methods:
336
+ if method in {ApiMethod.GET, ApiMethod.OPTIONS} and not body_is_optional:
337
+ assert body_typing is None, f'body must be empty for method {method.value}. "{body_typing.__name__}" was given'
338
+
339
+ return ApiResourceFnTyping(
340
+ fn=fn,
341
+ api_resource_type=api_resource_type,
342
+
343
+ signatures_typing=[(k, v) for k, v in fn.__annotations__.items() if k not in FN_SYSTEM_PROPS],
344
+
345
+ body_typing=body_typing,
346
+ has_body_validation_error=has_body_validation_error,
347
+ request_body_many=request_many,
348
+ request_body_optional=body_is_optional,
349
+
350
+ query_typing=query_typing,
351
+ has_query_validation_error=has_query_validation_error,
352
+
353
+ return_typing=return_typing,
354
+ return_payload_typing=return_payload_typing,
355
+ response_payload_many=response_many,
356
+ )
@@ -0,0 +1,16 @@
1
+ from enum import Enum
2
+
3
+ from ul_api_utils.errors import ResourceRuntimeApiError
4
+
5
+
6
+ class ApiResourceType(Enum):
7
+ WEB = 'web'
8
+ API = 'api'
9
+ FILE = 'file'
10
+
11
+ def __repr__(self) -> str:
12
+ return f'{type(self).__name__}.{self.name}'
13
+
14
+ def validate(self, type: 'ApiResourceType') -> None:
15
+ if type is not self:
16
+ raise ResourceRuntimeApiError(f'invalid usage method for api with type {type.value}. only {self.value} are permitted')
@@ -0,0 +1,300 @@
1
+ import io
2
+ from datetime import datetime
3
+ from typing import TypeVar, Generic, List, Optional, Dict, Any, Callable, Union, BinaryIO, Tuple, Type
4
+
5
+ import msgpack
6
+ from flask import jsonify, send_file, redirect, Response, request
7
+ from pydantic import BaseModel, Field
8
+ from pydantic import ConfigDict, RootModel
9
+ from werkzeug import Response as BaseResponse
10
+
11
+ from ul_api_utils.api_resource.db_types import TPayloadInputUnion
12
+ from ul_api_utils.const import RESPONSE_PROP_OK, RESPONSE_PROP_PAYLOAD, RESPONSE_PROP_COUNT, RESPONSE_PROP_TOTAL, \
13
+ RESPONSE_PROP_ERRORS, MIME__JSON, MIME__MSGPCK, \
14
+ REQUEST_HEADER__ACCEPT
15
+ from ul_api_utils.debug.debugger import Debugger
16
+ from ul_api_utils.utils.json_encoder import CustomJSONEncoder
17
+
18
+
19
+ def msgpackify(response: Any) -> Response:
20
+ flask_response = jsonify()
21
+ enc = CustomJSONEncoder()
22
+ flask_response.data = msgpack.packb(response, default=enc.default)
23
+ flask_response.content_type = MIME__MSGPCK
24
+ return flask_response
25
+
26
+
27
+ def save_generic_params(cls):
28
+ """
29
+ Wrapper for saving generic params in class
30
+ (to avoid GenericBeforeBaseModelWarning when change order class inheritance)
31
+ """
32
+ original_class_getitem = cls.__class_getitem__
33
+
34
+ @classmethod
35
+ def new_class_getitem(cls, params: Any) -> Any:
36
+ result = original_class_getitem(params)
37
+ result._generic_params = params if isinstance(params, tuple) else (params,)
38
+ return result
39
+
40
+ cls.__class_getitem__ = new_class_getitem
41
+ cls._generic_params = None
42
+ return cls
43
+
44
+
45
+ class ApiResponse(BaseModel):
46
+ ok: bool
47
+ status_code: int
48
+ headers: Dict[str, str] = {}
49
+
50
+ model_config = ConfigDict(
51
+ extra="forbid",
52
+ arbitrary_types_allowed=True
53
+ )
54
+
55
+ @classmethod
56
+ def _internal_use__mk_schema(cls, inner_type: Optional[Type[BaseModel]]) -> Type[BaseModel]:
57
+ raise NotImplementedError()
58
+
59
+ def to_flask_response(self, debugger: Optional[Debugger] = None) -> BaseResponse:
60
+ raise NotImplementedError()
61
+
62
+
63
+ class RedirectApiResponse(ApiResponse):
64
+ location: str
65
+ status_code: int = 302
66
+
67
+ def to_flask_response(self, debugger: Optional[Debugger] = None) -> BaseResponse:
68
+ resp = redirect(
69
+ self.location,
70
+ code=self.status_code,
71
+ )
72
+
73
+ resp.headers.update(self.headers)
74
+
75
+ return resp
76
+
77
+
78
+ class HtmlApiResponse(ApiResponse):
79
+ content: str
80
+ error: Optional[Exception] = None
81
+
82
+ @classmethod
83
+ def _internal_use__mk_schema(cls, inner_type: Optional[Type[BaseModel]]) -> Type[BaseModel]:
84
+ class _ResponseNoneType(RootModel[None]):
85
+ pass
86
+ return _ResponseNoneType
87
+
88
+ def to_flask_response(self, debugger: Optional[Debugger] = None) -> BaseResponse:
89
+ content = self.content
90
+ debugger_content = debugger.render_html(self.status_code) if debugger is not None else ''
91
+
92
+ if debugger_content:
93
+ prev_len = len(content)
94
+ content = content.replace('</body>', f'{debugger_content}</body>', 1)
95
+ if len(content) == prev_len:
96
+ content += debugger_content
97
+
98
+ resp = Response(
99
+ content,
100
+ status=self.status_code,
101
+ )
102
+
103
+ resp.headers.update(self.headers)
104
+
105
+ return resp
106
+
107
+
108
+ class FileApiResponse(ApiResponse):
109
+ file_path: Union[str, BinaryIO, io.BytesIO]
110
+ mimetype: Optional[str] = None # it will be auto-detected by extension if mimetype==None
111
+ as_attachment: bool = False
112
+ download_name: Optional[str] = None
113
+ conditional: bool = True
114
+ etag: Union[bool, str] = True
115
+ last_modified: Optional[Union[datetime, int, float]] = None
116
+ max_age: Optional[Union[int, Callable[[Optional[str]], Optional[int]]]] = None
117
+
118
+ @classmethod
119
+ def _internal_use__mk_schema(cls, inner_type: Optional[Type[BaseModel]]) -> Type[BaseModel]:
120
+ class _ResponseNoneType(RootModel[None]):
121
+ pass
122
+ return _ResponseNoneType
123
+
124
+ def to_flask_response(self, debugger: Optional[Debugger] = None) -> BaseResponse:
125
+ resp = send_file(
126
+ path_or_file=self.file_path,
127
+ mimetype=self.mimetype,
128
+ as_attachment=self.as_attachment,
129
+ download_name=self.download_name,
130
+ conditional=self.conditional,
131
+ etag=self.etag,
132
+ last_modified=self.last_modified,
133
+ max_age=self.max_age,
134
+ )
135
+
136
+ resp.headers.update(self.headers)
137
+
138
+ return resp
139
+
140
+
141
+ class EmptyJsonApiResponse(ApiResponse):
142
+
143
+ @classmethod
144
+ def _internal_use__mk_schema(cls, inner_type: Optional[Type[BaseModel]]) -> Type[BaseModel]:
145
+ class _ResponseNoneType(RootModel[None]):
146
+ pass
147
+ return _ResponseNoneType
148
+
149
+ def to_flask_response(self, debugger: Optional[Debugger] = None) -> BaseResponse:
150
+ resp = Response(response=None, status=self.status_code, mimetype=MIME__JSON)
151
+ resp.headers.update(self.headers)
152
+ return resp
153
+
154
+
155
+ T = TypeVar("T")
156
+
157
+ class JsonApiResponsePayload(BaseModel):
158
+ model_config = ConfigDict(extra="ignore")
159
+
160
+
161
+ class RootJsonApiResponsePayload(RootModel[T]):
162
+ pass
163
+
164
+ TRootJsonApiResponsePayload = TypeVar('TRootJsonApiResponsePayload')
165
+
166
+ TResultPayloadUnion = Union[None, Dict[str, Any], JsonApiResponsePayload, TRootJsonApiResponsePayload, List[JsonApiResponsePayload], List[TRootJsonApiResponsePayload], List[Dict[str, Any]]]
167
+ TPayloadTotalUnion = Union[
168
+ Tuple[None, None],
169
+ Tuple[Dict[str, Any], None],
170
+ Tuple[JsonApiResponsePayload, None],
171
+ Tuple[TRootJsonApiResponsePayload, None],
172
+ Tuple[List[JsonApiResponsePayload], int],
173
+ Tuple[List[TRootJsonApiResponsePayload], int],
174
+ Tuple[List[Dict[str, Any]], int],
175
+ ]
176
+
177
+
178
+ class DictJsonApiResponsePayload(RootJsonApiResponsePayload[Dict[str, Any]]):
179
+ pass
180
+
181
+
182
+ TProxyPayload = TypeVar('TProxyPayload', bound=Union[JsonApiResponsePayload, List[JsonApiResponsePayload], RootJsonApiResponsePayload[Any], List[RootJsonApiResponsePayload[Any]], None])
183
+
184
+
185
+ @save_generic_params
186
+ class ProxyJsonApiResponse(EmptyJsonApiResponse, Generic[TProxyPayload]):
187
+ response: Dict[str, Any]
188
+
189
+ @classmethod
190
+ def _internal_use__mk_schema(cls, inner_type: Optional[Type[BaseModel]]) -> Type[BaseModel]:
191
+ class _ResponseStd(BaseModel):
192
+ ok: bool
193
+ payload: inner_type # type: ignore
194
+ errors: List[Dict[str, Any]]
195
+ total_count: Optional[int] = None
196
+ count: Optional[int] = None
197
+ return _ResponseStd
198
+
199
+ def to_flask_response(self, debugger: Optional[Debugger] = None) -> BaseResponse:
200
+ resp = jsonify(self.response)
201
+ resp.status_code = self.status_code
202
+ resp.headers.update(self.headers)
203
+ return resp
204
+
205
+
206
+ TJsonObjApiResponsePayload = TypeVar('TJsonObjApiResponsePayload')
207
+
208
+
209
+ @save_generic_params
210
+ class RootJsonApiResponse(EmptyJsonApiResponse, Generic[TJsonObjApiResponsePayload]):
211
+ root: TJsonObjApiResponsePayload
212
+
213
+ @classmethod
214
+ def _internal_use__mk_schema(cls, inner_type: Optional[Type[BaseModel]]) -> Type[BaseModel]:
215
+ class _ResponseStd(RootModel[inner_type]): # type: ignore
216
+ pass
217
+ return _ResponseStd
218
+
219
+ def to_flask_response(self, debugger: Optional[Debugger] = None) -> BaseResponse:
220
+ resp = jsonify(self.root)
221
+ resp.status_code = self.status_code
222
+ resp.headers.update(self.headers)
223
+ return resp
224
+
225
+
226
+ class AnyJsonApiResponse(ApiResponse):
227
+ payload: TPayloadInputUnion = Field(union_mode="left_to_right")
228
+ total_count: Optional[int] = None
229
+ errors: List[Dict[str, Any]] = []
230
+
231
+ @classmethod
232
+ def _internal_use__mk_schema(cls, inner_type: Optional[Type[BaseModel]]) -> Type[BaseModel]:
233
+ class _ResponseStd(BaseModel):
234
+ ok: bool
235
+ payload: Any = None
236
+ errors: List[Dict[str, Any]]
237
+ total_count: Optional[int] = None
238
+ count: Optional[int] = None
239
+ return _ResponseStd
240
+
241
+ def to_flask_response(self, debugger: Optional[Debugger] = None) -> BaseResponse:
242
+ list_props: Dict[str, Any] = {}
243
+ if self.total_count is not None and isinstance(self.payload, (tuple, list)):
244
+ list_props = {
245
+ RESPONSE_PROP_COUNT: len(self.payload),
246
+ RESPONSE_PROP_TOTAL: self.total_count,
247
+ }
248
+
249
+ data = {
250
+ RESPONSE_PROP_OK: self.ok,
251
+ RESPONSE_PROP_PAYLOAD: self.payload,
252
+ RESPONSE_PROP_ERRORS: self.errors,
253
+ **list_props,
254
+ **(debugger.render_dict(self.status_code) if debugger is not None else {}),
255
+ }
256
+
257
+ if self.ok and len(self.errors) == 0 and MIME__MSGPCK in request.headers.get(REQUEST_HEADER__ACCEPT, MIME__JSON):
258
+ resp = msgpackify(data)
259
+ else:
260
+ resp = jsonify(data)
261
+
262
+ resp.status_code = self.status_code
263
+
264
+ resp.headers.update(self.headers)
265
+
266
+ return resp
267
+
268
+ @staticmethod
269
+ def _internal_use_response_error(many: bool, status_code: int, errors: List[Dict[str, str]]) -> 'JsonApiResponse[TJsonObjApiResponsePayload]':
270
+ # TODO
271
+ # exc -> 0 -> error_location
272
+ # str type expected (type=type_error.str)
273
+
274
+ # pydantic.error_wrappers.ValidationError: 8 validation exc for JsonApiResponse
275
+ #
276
+ # ul_api_utils.exc.api_list_error.ApiValidationListError:
277
+ # validation exc: [
278
+ # {'error_type': 'body-validation-error', 'error_message': 'field required', 'error_location': ('id',), 'error_kind': 'value_error.missing'},
279
+ # {'error_type': 'body-validation-error', 'error_message': 'field required', 'error_location': ('date_created',), 'error_kind': 'value_error.missing'}
280
+
281
+ # exc can by Dict[str, str | Tuple[str]]
282
+
283
+ if many:
284
+ return JsonApiResponse(ok=False, total_count=0, payload={}, status_code=status_code, errors=errors)
285
+ return JsonApiResponse(ok=False, total_count=0, payload={}, status_code=status_code, errors=errors)
286
+
287
+
288
+ @save_generic_params
289
+ class JsonApiResponse(AnyJsonApiResponse, Generic[TJsonObjApiResponsePayload]):
290
+ _generic_params = None
291
+
292
+ @classmethod
293
+ def _internal_use__mk_schema(cls, inner_type: Optional[Type[BaseModel]]) -> Type[BaseModel]:
294
+ class _ResponseStd(BaseModel):
295
+ ok: bool
296
+ payload: inner_type # type: ignore
297
+ errors: List[Dict[str, Any]]
298
+ total_count: Optional[int] = None
299
+ count: Optional[int] = None
300
+ return _ResponseStd