wellapi 0.2.1__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.
wellapi/routing.py ADDED
@@ -0,0 +1,248 @@
1
+ import json
2
+ import re
3
+ from collections.abc import Callable
4
+ from typing import Any
5
+
6
+ from pydantic.main import IncEx
7
+
8
+ from wellapi.convertors import CONVERTOR_TYPES
9
+ from wellapi.datastructures import Default, DefaultPlaceholder
10
+ from wellapi.dependencies.models import Dependant, ModelField
11
+ from wellapi.dependencies.utils import solve_dependencies
12
+ from wellapi.exceptions import (
13
+ HTTPException,
14
+ RequestValidationError,
15
+ ResponseValidationError,
16
+ WellAPIError,
17
+ )
18
+ from wellapi.models import RequestAPIGateway, RequestJob, RequestSQS, ResponseAPIGateway
19
+
20
+ # Match parameters in URL paths, eg. '{param}', and '{param:int}'
21
+ PARAM_REGEX = re.compile("{([a-zA-Z_][a-zA-Z0-9_]*)(:[a-zA-Z_][a-zA-Z0-9_]*)?}")
22
+
23
+
24
+ def compile_path(path: str) -> tuple:
25
+ """
26
+ Given a path string, like: "/{username:str}",
27
+ or a host string, like: "{subdomain}.mydomain.org", return a three-tuple
28
+ of (regex, format, {param_name:convertor}).
29
+
30
+ regex: "/(?P<username>[^/]+)"
31
+ format: "/{username}"
32
+ convertors: {"username": StringConvertor()}
33
+ """
34
+ is_host = not path.startswith("/")
35
+
36
+ path_regex = "^"
37
+ path_format = ""
38
+ duplicated_params = set()
39
+
40
+ idx = 0
41
+ param_convertors = {}
42
+ for match in PARAM_REGEX.finditer(path):
43
+ param_name, convertor_type = match.groups("str")
44
+ convertor_type = convertor_type.lstrip(":")
45
+ assert convertor_type in CONVERTOR_TYPES, (
46
+ f"Unknown path convertor '{convertor_type}'"
47
+ )
48
+ convertor = CONVERTOR_TYPES[convertor_type]
49
+
50
+ path_regex += re.escape(path[idx : match.start()])
51
+ path_regex += f"(?P<{param_name}>{convertor.regex})"
52
+
53
+ path_format += path[idx : match.start()]
54
+ path_format += "{%s}" % param_name # noqa: UP031
55
+
56
+ if param_name in param_convertors:
57
+ duplicated_params.add(param_name)
58
+
59
+ param_convertors[param_name] = convertor
60
+
61
+ idx = match.end()
62
+
63
+ if duplicated_params:
64
+ names = ", ".join(sorted(duplicated_params))
65
+ ending = "s" if len(duplicated_params) > 1 else ""
66
+ raise ValueError(f"Duplicated param name{ending} {names} at path {path}")
67
+
68
+ if is_host:
69
+ # Align with `Host.matches()` behavior, which ignores port.
70
+ hostname = path[idx:].split(":")[0]
71
+ path_regex += re.escape(hostname) + "$"
72
+ else:
73
+ path_regex += re.escape(path[idx:]) + "$"
74
+
75
+ path_format += path[idx:]
76
+
77
+ return re.compile(path_regex), path_format, param_convertors
78
+
79
+
80
+ def get_request_handler(
81
+ dependant: Dependant,
82
+ status_code: int | None = None,
83
+ response_class: type[ResponseAPIGateway] | DefaultPlaceholder = Default(
84
+ ResponseAPIGateway
85
+ ),
86
+ response_field: ModelField | None = None,
87
+ response_model_include: IncEx | None = None,
88
+ response_model_exclude: IncEx | None = None,
89
+ response_model_by_alias: bool = True,
90
+ response_model_exclude_unset: bool = False,
91
+ response_model_exclude_defaults: bool = False,
92
+ response_model_exclude_none: bool = False,
93
+ embed_body_fields: bool = False,
94
+ ) -> Callable[[RequestAPIGateway], ResponseAPIGateway]:
95
+ assert dependant.call is not None, "dependant.call must be a function"
96
+
97
+ if isinstance(response_class, DefaultPlaceholder):
98
+ actual_response_class: type[ResponseAPIGateway] = response_class.value
99
+ else:
100
+ actual_response_class = response_class
101
+
102
+ def app(request: RequestAPIGateway) -> ResponseAPIGateway:
103
+ response: ResponseAPIGateway | None = None
104
+ try:
105
+ body: Any = request.json()
106
+ except json.JSONDecodeError as e:
107
+ validation_error = RequestValidationError(
108
+ [
109
+ {
110
+ "type": "json_invalid",
111
+ "loc": ("body", e.pos),
112
+ "msg": "JSON decode error",
113
+ "input": {},
114
+ "ctx": {"error": e.msg},
115
+ }
116
+ ],
117
+ body=e.doc,
118
+ )
119
+ raise validation_error from e
120
+ except HTTPException:
121
+ # If a middleware raises an HTTPException, it should be raised again
122
+ raise
123
+ except Exception as e:
124
+ http_error = HTTPException(
125
+ status_code=400, detail="There was an error parsing the body"
126
+ )
127
+ raise http_error from e
128
+ errors: list[Any] = []
129
+ solved_result = solve_dependencies(
130
+ request=request,
131
+ dependant=dependant,
132
+ body=body,
133
+ embed_body_fields=embed_body_fields,
134
+ )
135
+ errors = solved_result.errors
136
+ if not errors:
137
+ raw_response = dependant.call(**solved_result.values)
138
+ if isinstance(raw_response, ResponseAPIGateway):
139
+ response = raw_response
140
+ else:
141
+ response_args: dict[str, Any] = {}
142
+ # If status_code was set, use it, otherwise use the default from the
143
+ # response class, in the case of redirect it's 307
144
+ current_status_code = (
145
+ status_code if status_code else solved_result.response.statusCode
146
+ )
147
+ if current_status_code is not None:
148
+ response_args["status_code"] = current_status_code
149
+ if solved_result.response.statusCode:
150
+ response_args["status_code"] = solved_result.response.statusCode
151
+ content = serialize_response(
152
+ field=response_field,
153
+ response_content=raw_response,
154
+ include=response_model_include,
155
+ exclude=response_model_exclude,
156
+ by_alias=response_model_by_alias,
157
+ exclude_unset=response_model_exclude_unset,
158
+ exclude_defaults=response_model_exclude_defaults,
159
+ exclude_none=response_model_exclude_none,
160
+ )
161
+ response = actual_response_class(content, **response_args)
162
+ if not is_body_allowed_for_status_code(response.statusCode):
163
+ response.body = ""
164
+ response.headers.raw.extend(solved_result.response.headers.raw)
165
+ if errors:
166
+ validation_error = RequestValidationError(errors, body=body)
167
+ raise validation_error
168
+ if response is None:
169
+ raise WellAPIError(
170
+ "No response object was returned. There's a high chance that the "
171
+ "application code is raising an exception and a dependency with yield "
172
+ "has a block with a bare except, or a block with except Exception, "
173
+ "and is not raising the exception again. Read more about it in the "
174
+ "docs: https://fastapi.tiangolo.com/tutorial/dependencies/dependencies-with-yield/#dependencies-with-yield-and-except"
175
+ )
176
+ return response
177
+
178
+ return app
179
+
180
+
181
+ def serialize_response(
182
+ *,
183
+ field: ModelField | None = None,
184
+ response_content: Any,
185
+ include: IncEx | None = None,
186
+ exclude: IncEx | None = None,
187
+ by_alias: bool = True,
188
+ exclude_unset: bool = False,
189
+ exclude_defaults: bool = False,
190
+ exclude_none: bool = False,
191
+ ) -> Any:
192
+ if field:
193
+ errors = []
194
+ value, errors_ = field.validate(response_content, {}, loc=("response",))
195
+
196
+ if isinstance(errors_, list):
197
+ errors.extend(errors_)
198
+ elif errors_:
199
+ errors.append(errors_)
200
+ if errors:
201
+ raise ResponseValidationError(errors=errors, body=response_content)
202
+
203
+ return field.serialize(
204
+ value,
205
+ include=include,
206
+ exclude=exclude,
207
+ by_alias=by_alias,
208
+ exclude_unset=exclude_unset,
209
+ exclude_defaults=exclude_defaults,
210
+ exclude_none=exclude_none,
211
+ )
212
+ else:
213
+ return json.dumps(response_content)
214
+
215
+
216
+ def is_body_allowed_for_status_code(status_code: int | str | None) -> bool:
217
+ if status_code is None:
218
+ return True
219
+ # Ref: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#patterned-fields-1
220
+ if status_code in {
221
+ "default",
222
+ "1XX",
223
+ "2XX",
224
+ "3XX",
225
+ "4XX",
226
+ "5XX",
227
+ }:
228
+ return True
229
+ current_status_code = int(status_code)
230
+ return not (current_status_code < 200 or current_status_code in {204, 205, 304})
231
+
232
+
233
+ def request_response(
234
+ func: Callable[[RequestAPIGateway], ResponseAPIGateway],
235
+ ) -> Callable[[dict, dict], dict]:
236
+ def app(event: dict, context: dict) -> dict:
237
+ if "Records" in event:
238
+ request = RequestSQS.create_request_from_event(event)
239
+ elif "source" in event and event["source"] == "aws.events":
240
+ request = RequestJob.create_request_from_event(event)
241
+ else:
242
+ request = RequestAPIGateway.create_request_from_event(event)
243
+
244
+ resp = func(request)
245
+
246
+ return resp.to_aws_response()
247
+
248
+ return app
wellapi/security.py ADDED
@@ -0,0 +1,82 @@
1
+ from typing import Any, cast
2
+
3
+ from wellapi.exceptions import HTTPException
4
+ from wellapi.models import RequestAPIGateway
5
+ from wellapi.openapi.models import OAuth2 as OAuth2Model
6
+ from wellapi.openapi.models import OAuthFlows as OAuthFlowsModel
7
+ from wellapi.openapi.models import SecurityBase as SecurityBaseModel
8
+
9
+
10
+ class SecurityBase:
11
+ model: SecurityBaseModel
12
+ scheme_name: str
13
+
14
+
15
+ class OAuth2(SecurityBase):
16
+ def __init__(
17
+ self,
18
+ *,
19
+ flows: OAuthFlowsModel | dict[str, dict[str, Any]] = OAuthFlowsModel(),
20
+ scheme_name: str | None = None,
21
+ description: str | None = None,
22
+ auto_error: bool = True,
23
+ ):
24
+ self.model = OAuth2Model(
25
+ flows=cast(OAuthFlowsModel, flows), description=description
26
+ )
27
+ self.scheme_name = scheme_name or self.__class__.__name__
28
+ self.auto_error = auto_error
29
+
30
+ def __call__(self, request: RequestAPIGateway) -> str | None:
31
+ authorization = request.headers.get("Authorization")
32
+ if not authorization:
33
+ if self.auto_error:
34
+ raise HTTPException(status_code=403, detail="Not authenticated")
35
+ else:
36
+ return None
37
+ return authorization
38
+
39
+
40
+ class OAuth2PasswordBearer(OAuth2):
41
+ def __init__(
42
+ self,
43
+ tokenUrl: str,
44
+ scheme_name: str | None = None,
45
+ scopes: dict[str, str] | None = None,
46
+ description: str | None = None,
47
+ auto_error: bool = True,
48
+ ):
49
+ if not scopes:
50
+ scopes = {}
51
+ flows = OAuthFlowsModel(
52
+ password=cast(Any, {"tokenUrl": tokenUrl, "scopes": scopes})
53
+ )
54
+ super().__init__(
55
+ flows=flows,
56
+ scheme_name=scheme_name,
57
+ description=description,
58
+ auto_error=auto_error,
59
+ )
60
+
61
+ def __call__(self, request: RequestAPIGateway) -> str | None:
62
+ authorization = request.headers.get("Authorization")
63
+ scheme, param = get_authorization_scheme_param(authorization)
64
+ if not authorization or scheme.lower() != "bearer":
65
+ if self.auto_error:
66
+ raise HTTPException(
67
+ status_code=401,
68
+ detail="Not authenticated",
69
+ headers={"WWW-Authenticate": "Bearer"},
70
+ )
71
+ else:
72
+ return None
73
+ return param
74
+
75
+
76
+ def get_authorization_scheme_param(
77
+ authorization_header_value: str | None,
78
+ ) -> tuple[str, str]:
79
+ if not authorization_header_value:
80
+ return "", ""
81
+ scheme, _, param = authorization_header_value.partition(" ")
82
+ return scheme, param
wellapi/utils.py ADDED
@@ -0,0 +1,37 @@
1
+ import importlib
2
+ import os
3
+ import sys
4
+ from pathlib import Path
5
+
6
+
7
+ def import_app(app: str):
8
+ app_modal, app_name = app.split(":")
9
+ app_path = f"{os.path.abspath(Path(app_modal))}.py"
10
+
11
+ spec = importlib.util.spec_from_file_location(app_modal, app_path)
12
+ main = importlib.util.module_from_spec(spec)
13
+ sys.modules[app_modal] = main
14
+ spec.loader.exec_module(main)
15
+
16
+ return getattr(main, app_name)
17
+
18
+
19
+ def load_handlers(handlers_dir: str):
20
+ handlers_path = Path(handlers_dir)
21
+ handlers_module = handlers_path.name
22
+ base_path = Path(os.path.dirname(handlers_path))
23
+
24
+ if not handlers_path.exists() or not handlers_path.is_dir():
25
+ print(f"Директорія {handlers_path} не існує")
26
+ return
27
+
28
+ # Додаємо шлях до директорії у sys.path для імпорту
29
+ sys.path.insert(0, str(base_path))
30
+
31
+ # Імпортуємо всі Python файли з директорії
32
+ for file_path in handlers_path.glob("*.py"):
33
+ module_name = f"{handlers_module}.{file_path.stem}"
34
+ try:
35
+ importlib.import_module(module_name)
36
+ except ImportError as e:
37
+ print(f"Помилка імпорту {module_name}: {e}")
@@ -0,0 +1,32 @@
1
+ Metadata-Version: 2.4
2
+ Name: wellapi
3
+ Version: 0.2.1
4
+ Summary: Add your description here
5
+ Author-email: ramses <romayuhym@gmail.com>
6
+ Requires-Python: >=3.12
7
+ Requires-Dist: aws-cdk-lib>=2.189.1
8
+ Requires-Dist: email-validator>=2.0.0
9
+ Requires-Dist: pydantic==2.11.3
10
+ Requires-Dist: typing-extensions>=4.8.0
11
+ Provides-Extra: standard
12
+ Requires-Dist: click>=8.1.8; extra == 'standard'
13
+ Requires-Dist: watchdog>=4.0.2; extra == 'standard'
14
+ Description-Content-Type: text/markdown
15
+
16
+ TODO
17
+
18
+ - [ ] OpenAPI
19
+ - [X] Request validation
20
+ - [ ] CORS
21
+ - [ ] x-api-key
22
+ - [ ] cache
23
+ - [ ] access log
24
+ - [ ] Build
25
+ - [ ] Clean up
26
+ - [x] Use multi Value Request Parameters (Headers, QueryString)
27
+ - [ ] Check AWS warning
28
+
29
+ The API with ID 6526xo4n92 doesn’t include a resource with path /* having an integration arn:aws:lambda:eu-central-1:985539757029:function:MainHelloFunction on the ANY method.
30
+
31
+ The API with ID doesn’t include a resource with path /* having an integration on the ANY method.
32
+ - [ ] [Dependency groups](https://docs.astral.sh/uv/concepts/projects/dependencies/)
@@ -0,0 +1,38 @@
1
+ wellapi/__init__.py,sha256=N6ifyrbNKflKx8oJ7xYi3vV5SWLuiQMqJJtUxU5K3Ww,115
2
+ wellapi/__main__.py,sha256=nEdSvtvjzSyjlucTTawydRW7_Yrn-8tIvpbn-k4s3pQ,33
3
+ wellapi/applications.py,sha256=U6vThhLg3qQtC9rCa0PtEHK1DEtfqzeqaVX1cwlxJRU,14468
4
+ wellapi/awsmodel.py,sha256=Vnx_HcQUAxH5EKhZRFwd7CsThZ2RPaO6asF3qX4EZZk,303
5
+ wellapi/convertors.py,sha256=Z8alTWkTTahOYLw-O6oLpd7kvXT11qtF4lbRI_7yq_o,2317
6
+ wellapi/datastructures.py,sha256=Wl6dMoD-E1F5ZozoYQecZZv-wNfIxCvCLTM9hE97FBM,12923
7
+ wellapi/exceptions.py,sha256=FvcBiLayr27drUy8xVX5y3SCXlNrm8DAyZOvFjnk8VE,1495
8
+ wellapi/models.py,sha256=dXhBcKsQuuQv4Ja7DTg2wOz9W5xxbM0qcc-57VE0MnY,4271
9
+ wellapi/params.py,sha256=cwrXYmFarMP1r3OqgQ6aeLK4rleprcwn6Canwhz-c20,16190
10
+ wellapi/routing.py,sha256=2n9K4KnlMhkCtiwn1aUHmwzcKZkKZbtmEAQQv9usXh8,8999
11
+ wellapi/security.py,sha256=qdSETiJZXIrbbSJFN9Ilyk-U3nagzYpE1XZtA_IFtWE,2625
12
+ wellapi/utils.py,sha256=AH45QMkL96FTizoIBGBIzSBelrMeWdr477rJRpw_bfo,1220
13
+ wellapi/build/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
+ wellapi/build/cdk.py,sha256=BLMRQ8cEHyj2q3jHEC7rduIyeHwebHokYRgxkdYb2v4,4648
15
+ wellapi/build/packager.py,sha256=cmQaEAVT_F7X2tkFSZug9LZJpfpRO54ysoYtfde18w0,1883
16
+ wellapi/build/sam_openapi.py,sha256=SAbgR5HnscCNEVXQBH7eMlJd9h0jeW17mbFTeJiwHiI,356
17
+ wellapi/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
18
+ wellapi/cli/main.py,sha256=75VhVYryXjadu3Tr_7CKCRbDMOiwLXGXxoA5oYRt7DA,1966
19
+ wellapi/dependencies/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
20
+ wellapi/dependencies/models.py,sha256=GIagLUOKFp9FndQlSrmcRXcvlj2kaaSdXdFvB0aGHcU,4322
21
+ wellapi/dependencies/utils.py,sha256=qcx38SjVtYlFIRKUhWbBwu0TmnS9KAK_VBF1ZqlIwGE,32581
22
+ wellapi/local/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
23
+ wellapi/local/reloader.py,sha256=BgF4zSipXhfX-Lk9NkKBaaTCtIz_gV9p1sSkeADP67c,3190
24
+ wellapi/local/router.py,sha256=m_1LZ-DGL2TPJyq8Czrb-cc7HFMdKhqgfjyw9lFPeyQ,3294
25
+ wellapi/local/server.py,sha256=wKS-jarw66wXhAGfTpYbZqtH1bmP4wY7rz5rNqhU9wA,5611
26
+ wellapi/middleware/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
27
+ wellapi/middleware/base.py,sha256=0zAN4ydARbVf-3_q_9KVEep4LBvUDklhl_zEWjnIlwM,647
28
+ wellapi/middleware/error.py,sha256=ozpCk980nkpQa_jWLv0J4YxKvWUtnP8RJcv0HyvCGGI,7066
29
+ wellapi/middleware/exceptions.py,sha256=pQGWR5lnJcSvkBDhPg54CKg4ximgaYWNJU3fJ5caRLI,2370
30
+ wellapi/middleware/main.py,sha256=orMTtLPETezQOeFsrPju9p68v2Cdi3EZ2iHi9UzMV30,761
31
+ wellapi/openapi/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
32
+ wellapi/openapi/docs.py,sha256=bCwrv9OlJNfh3WA5gKlsbMYM1irYkDi5fP9y8Y2WNMg,10323
33
+ wellapi/openapi/models.py,sha256=9BD9G2FrstD4uuZZ2aHSrCBoWuU_jv2gQwo7LBWbf7c,13434
34
+ wellapi/openapi/utils.py,sha256=ecPSmcS6FUzbU6-ATYwuEgLbXa8wj55jmbjHhT7xECg,20147
35
+ wellapi-0.2.1.dist-info/METADATA,sha256=xkSpufFydjtrc5CLhrqSDoHriIeLG5YnqiCpL4iX4XM,1063
36
+ wellapi-0.2.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
37
+ wellapi-0.2.1.dist-info/entry_points.txt,sha256=O4XF2o637XkqrRiaZjPXxU_P1y6-2sRaYaBw9Jc7IJM,49
38
+ wellapi-0.2.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ wellapi = wellapi.cli.main:cli