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/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ import typing
2
+
3
+ from wellapi.applications import WellApi as WellApi
4
+
5
+ Scope = typing.MutableMapping[str, typing.Any]
wellapi/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli.main import cli
2
+
3
+ cli()
@@ -0,0 +1,389 @@
1
+ import inspect
2
+ from collections.abc import Callable, Sequence
3
+ from enum import Enum
4
+ from typing import Any, Literal
5
+
6
+ from pydantic._internal._utils import lenient_issubclass
7
+ from pydantic.main import IncEx
8
+
9
+ from wellapi import params
10
+ from wellapi.datastructures import Default, DefaultPlaceholder
11
+ from wellapi.dependencies.models import ModelField
12
+ from wellapi.dependencies.utils import (
13
+ _should_embed_body_fields,
14
+ create_model_field,
15
+ get_body_field,
16
+ get_dependant,
17
+ get_flat_dependant,
18
+ get_parameterless_sub_dependant,
19
+ get_typed_return_annotation,
20
+ )
21
+ from wellapi.middleware.base import BaseMiddleware
22
+ from wellapi.middleware.error import ServerErrorMiddleware
23
+ from wellapi.middleware.exceptions import ExceptionMiddleware
24
+ from wellapi.middleware.main import Middleware
25
+ from wellapi.models import RequestAPIGateway, ResponseAPIGateway
26
+ from wellapi.routing import (
27
+ compile_path,
28
+ get_request_handler,
29
+ is_body_allowed_for_status_code,
30
+ request_response,
31
+ )
32
+
33
+
34
+ def to_camel_case(snake_str):
35
+ return "".join(x.capitalize() for x in snake_str.lower().split("_"))
36
+
37
+
38
+ def get_arn(endpoint: Callable[..., Any]) -> str:
39
+ name = f"{endpoint.__module__}.{endpoint.__name__}"
40
+ name = name.replace(".", "_")
41
+ return to_camel_case(name)
42
+
43
+
44
+ class Lambda:
45
+ def __init__(
46
+ self,
47
+ path: str,
48
+ endpoint: Callable[..., Any],
49
+ *,
50
+ method: str = None,
51
+ type_: Literal["endpoint", "queue", "job"] = "endpoint",
52
+ name: str | None = None,
53
+ memory_size: int = 128,
54
+ timeout: int = 30,
55
+ response_model: Any = Default(None),
56
+ status_code: int | None = None,
57
+ description: str | None = None,
58
+ response_description: str = "Successful Response",
59
+ responses: dict[int | str, dict[str, Any]] | None = None,
60
+ response_class: type[ResponseAPIGateway] | DefaultPlaceholder = Default(
61
+ ResponseAPIGateway
62
+ ),
63
+ response_model_include: IncEx | None = None,
64
+ response_model_exclude: IncEx | None = None,
65
+ response_model_by_alias: bool = True,
66
+ response_model_exclude_unset: bool = False,
67
+ response_model_exclude_defaults: bool = False,
68
+ response_model_exclude_none: bool = False,
69
+ dependencies: Sequence[params.Depends] | None = None,
70
+ tags: list[str | Enum] | None = None,
71
+ deprecated: bool | None = None,
72
+ operation_id: str | None = None,
73
+ summary: str | None = None,
74
+ ):
75
+ self.path = path
76
+ self.type_ = type_
77
+ self.name = endpoint.__name__ if name is None else name
78
+ self.memory_size = memory_size
79
+ self.timeout = timeout
80
+ self.method = method
81
+ self.endpoint = endpoint
82
+ self.path_regex, self.path_format, self.param_convertors = compile_path(path)
83
+ self.dependencies = list(dependencies or [])
84
+ self.operation_id = operation_id
85
+ self.unique_id = (
86
+ self.operation_id or f"{self.endpoint.__module__}.{self.endpoint.__name__}"
87
+ )
88
+ self.status_code = status_code
89
+ self.response_description = response_description
90
+ self.response_class = response_class
91
+ self.response_model_include = response_model_include
92
+ self.response_model_exclude = response_model_exclude
93
+ self.response_model_by_alias = response_model_by_alias
94
+ self.response_model_exclude_unset = response_model_exclude_unset
95
+ self.response_model_exclude_defaults = response_model_exclude_defaults
96
+ self.response_model_exclude_none = response_model_exclude_none
97
+ self.tags = tags or []
98
+ self.deprecated = deprecated
99
+ self.summary = summary
100
+ self.arn = get_arn(self.endpoint)
101
+
102
+ if isinstance(response_model, DefaultPlaceholder):
103
+ return_annotation = get_typed_return_annotation(endpoint)
104
+ if lenient_issubclass(return_annotation, ResponseAPIGateway):
105
+ response_model = None
106
+ else:
107
+ response_model = return_annotation
108
+ self.response_model = response_model
109
+ if self.response_model:
110
+ assert is_body_allowed_for_status_code(status_code), (
111
+ f"Status code {status_code} must not have a response body"
112
+ )
113
+ response_name = "Response_" + self.unique_id
114
+ self.response_field = create_model_field(
115
+ name=response_name,
116
+ type_=self.response_model,
117
+ mode="serialization",
118
+ )
119
+ else:
120
+ self.response_field = None # type: ignore
121
+ self.secure_cloned_response_field = None
122
+
123
+ self.description = description or inspect.cleandoc(self.endpoint.__doc__ or "")
124
+ # if a "form feed" character (page break) is found in the description text,
125
+ # truncate description text to the content preceding the first "form feed"
126
+ self.description = self.description.split("\f")[0].strip()
127
+ response_fields = {}
128
+ self.responses = responses or {}
129
+ for additional_status_code, response in self.responses.items():
130
+ assert isinstance(response, dict), "An additional response must be a dict"
131
+ model = response.get("model")
132
+ if model:
133
+ assert is_body_allowed_for_status_code(additional_status_code), (
134
+ f"Status code {additional_status_code} must not have a response body"
135
+ )
136
+ response_name = f"Response_{additional_status_code}_{self.unique_id}"
137
+ response_field = create_model_field(
138
+ name=response_name, type_=model, mode="serialization"
139
+ )
140
+ response_fields[additional_status_code] = response_field
141
+ if response_fields:
142
+ self.response_fields: dict[int | str, ModelField] = response_fields
143
+ else:
144
+ self.response_fields = {}
145
+
146
+ assert callable(endpoint), "An endpoint must be a callable"
147
+ self.dependant = get_dependant(
148
+ path=self.path_format, call=self.endpoint, type_=self.type_
149
+ )
150
+ for depends in self.dependencies[::-1]:
151
+ self.dependant.dependencies.insert(
152
+ 0,
153
+ get_parameterless_sub_dependant(
154
+ depends=depends, path=self.path_format, type_=self.type_
155
+ ),
156
+ )
157
+ self._flat_dependant = get_flat_dependant(self.dependant)
158
+ self._embed_body_fields = _should_embed_body_fields(
159
+ self._flat_dependant.body_params
160
+ )
161
+ self.body_field = get_body_field(
162
+ flat_dependant=self._flat_dependant,
163
+ name=self.unique_id,
164
+ embed_body_fields=self._embed_body_fields,
165
+ )
166
+ self.app = self.get_route_handler()
167
+
168
+ def get_route_handler(self):
169
+ return get_request_handler(
170
+ dependant=self.dependant,
171
+ status_code=self.status_code,
172
+ response_class=self.response_class,
173
+ response_field=self.response_field,
174
+ response_model_include=self.response_model_include,
175
+ response_model_exclude=self.response_model_exclude,
176
+ response_model_by_alias=self.response_model_by_alias,
177
+ response_model_exclude_unset=self.response_model_exclude_unset,
178
+ response_model_exclude_defaults=self.response_model_exclude_defaults,
179
+ response_model_exclude_none=self.response_model_exclude_none,
180
+ embed_body_fields=self._embed_body_fields,
181
+ )
182
+
183
+
184
+ class WellApi:
185
+ def __init__(
186
+ self,
187
+ title: str = "WellApi",
188
+ version: str = "0.1.0",
189
+ description: str = "",
190
+ openapi_tags: list[dict[str, Any]] | None = None,
191
+ servers: list[dict[str, str | Any]] | None = None,
192
+ queues: list[dict[str, Any]] | None = None,
193
+ debug: bool = False,
194
+ ):
195
+ self.lambdas = []
196
+ self.exception_handlers = {}
197
+ self.user_middleware = []
198
+ self.debug = debug
199
+ self.title = title
200
+ self.version = version
201
+ self.description = description
202
+ self.openapi_tags = openapi_tags
203
+ self.servers = servers
204
+ self.queues = queues or []
205
+
206
+ def build_middleware_stack(self, app: Callable) -> Callable:
207
+ debug = self.debug
208
+ error_handler = None
209
+ exception_handlers: dict[
210
+ Any, Callable[[RequestAPIGateway, Exception], ResponseAPIGateway]
211
+ ] = {}
212
+
213
+ for key, value in self.exception_handlers.items():
214
+ if key in (500, Exception):
215
+ error_handler = value
216
+ else:
217
+ exception_handlers[key] = value
218
+
219
+ middleware = (
220
+ [Middleware(ServerErrorMiddleware, handler=error_handler, debug=debug)]
221
+ + self.user_middleware
222
+ + [
223
+ Middleware(
224
+ ExceptionMiddleware, handlers=exception_handlers, debug=debug
225
+ )
226
+ ]
227
+ )
228
+
229
+ for cls, args, kwargs in reversed(middleware):
230
+ app = cls(app, *args, **kwargs)
231
+ return request_response(app)
232
+
233
+ def add_endpoint(self, *args, **kwargs):
234
+ lambda_ = Lambda(*args, **kwargs)
235
+ self.lambdas.append(lambda_)
236
+
237
+ def endpoint(event, context):
238
+ return self.build_middleware_stack(lambda_.app)(event, context)
239
+
240
+ return endpoint
241
+
242
+ def add_exception_handler(
243
+ self,
244
+ exc_class_or_status_code: int | type[Exception],
245
+ handler: Callable[[RequestAPIGateway, Exception], ResponseAPIGateway],
246
+ ) -> None: # pragma: no cover
247
+ self.exception_handlers[exc_class_or_status_code] = handler
248
+
249
+ def exception_handler(
250
+ self, exc_class_or_status_code: int | type[Exception]
251
+ ) -> Callable:
252
+ def decorator(func):
253
+ self.add_exception_handler(exc_class_or_status_code, func)
254
+ return func
255
+
256
+ return decorator
257
+
258
+ def add_middleware(self, middleware_class, dispatch) -> None:
259
+ self.user_middleware.insert(0, Middleware(middleware_class, dispatch))
260
+
261
+ def middleware(self) -> Callable:
262
+ def decorator(func):
263
+ self.add_middleware(BaseMiddleware, dispatch=func)
264
+ return func
265
+
266
+ return decorator
267
+
268
+ def get(
269
+ self,
270
+ path: str,
271
+ *,
272
+ response_model: Any = Default(None),
273
+ status_code: int | None = None,
274
+ response_class: type[ResponseAPIGateway] | DefaultPlaceholder = Default(
275
+ ResponseAPIGateway
276
+ ),
277
+ response_model_include: IncEx | None = None,
278
+ response_model_exclude: IncEx | None = None,
279
+ response_model_by_alias: bool = True,
280
+ response_model_exclude_unset: bool = False,
281
+ response_model_exclude_defaults: bool = False,
282
+ response_model_exclude_none: bool = False,
283
+ dependencies: Sequence[params.Depends] | None = None,
284
+ tags: list[str | Enum] | None = None,
285
+ ):
286
+ def decorator(func):
287
+ lambda_ = self.add_endpoint(
288
+ path,
289
+ func,
290
+ type_="endpoint",
291
+ response_model=response_model,
292
+ status_code=status_code,
293
+ method="GET",
294
+ response_class=response_class,
295
+ response_model_include=response_model_include,
296
+ response_model_exclude=response_model_exclude,
297
+ response_model_by_alias=response_model_by_alias,
298
+ response_model_exclude_unset=response_model_exclude_unset,
299
+ response_model_exclude_defaults=response_model_exclude_defaults,
300
+ response_model_exclude_none=response_model_exclude_none,
301
+ dependencies=dependencies,
302
+ tags=tags,
303
+ )
304
+ return lambda_
305
+
306
+ return decorator
307
+
308
+ def post(
309
+ self,
310
+ path: str,
311
+ *,
312
+ response_model: Any = Default(None),
313
+ status_code: int | None = None,
314
+ response_class: type[ResponseAPIGateway] | DefaultPlaceholder = Default(
315
+ ResponseAPIGateway
316
+ ),
317
+ response_model_include: IncEx | None = None,
318
+ response_model_exclude: IncEx | None = None,
319
+ response_model_by_alias: bool = True,
320
+ response_model_exclude_unset: bool = False,
321
+ response_model_exclude_defaults: bool = False,
322
+ response_model_exclude_none: bool = False,
323
+ dependencies: Sequence[params.Depends] | None = None,
324
+ tags: list[str | Enum] | None = None,
325
+ ):
326
+ def decorator(func):
327
+ lambda_ = self.add_endpoint(
328
+ path,
329
+ func,
330
+ type_="endpoint",
331
+ response_model=response_model,
332
+ status_code=status_code,
333
+ method="POST",
334
+ response_class=response_class,
335
+ response_model_include=response_model_include,
336
+ response_model_exclude=response_model_exclude,
337
+ response_model_by_alias=response_model_by_alias,
338
+ response_model_exclude_unset=response_model_exclude_unset,
339
+ response_model_exclude_defaults=response_model_exclude_defaults,
340
+ response_model_exclude_none=response_model_exclude_none,
341
+ dependencies=dependencies,
342
+ tags=tags,
343
+ )
344
+ return lambda_
345
+
346
+ return decorator
347
+
348
+ def sqs(
349
+ self,
350
+ queue_name: str,
351
+ memory_size: int = 128,
352
+ timeout: int = 30,
353
+ dependencies: Sequence[params.Depends] | None = None,
354
+ ):
355
+ def decorator(func):
356
+ lambda_ = self.add_endpoint(
357
+ queue_name,
358
+ func,
359
+ type_="queue",
360
+ memory_size=memory_size,
361
+ timeout=timeout,
362
+ dependencies=dependencies,
363
+ )
364
+ return lambda_
365
+
366
+ return decorator
367
+
368
+ def job(
369
+ self,
370
+ expression: str,
371
+ name: str | None = None,
372
+ memory_size: int = 128,
373
+ timeout: int = 30,
374
+ *,
375
+ dependencies: Sequence[params.Depends] | None = None,
376
+ ):
377
+ def decorator(func):
378
+ lambda_ = self.add_endpoint(
379
+ expression,
380
+ func,
381
+ type_="job",
382
+ name=name,
383
+ memory_size=memory_size,
384
+ timeout=timeout,
385
+ dependencies=dependencies,
386
+ )
387
+ return lambda_
388
+
389
+ return decorator
wellapi/awsmodel.py ADDED
@@ -0,0 +1,17 @@
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class Message(BaseModel):
5
+ messageId: str
6
+ receiptHandle: str
7
+ body: str
8
+ attributes: dict
9
+ messageAttributes: dict
10
+ md5OfBody: str
11
+ eventSource: str
12
+ eventSourceARN: str
13
+ awsRegion: str
14
+
15
+
16
+ class SQSEvent(BaseModel):
17
+ Records: list[Message]
File without changes
wellapi/build/cdk.py ADDED
@@ -0,0 +1,141 @@
1
+ import json
2
+ import os
3
+
4
+ # ruff: noqa: I001
5
+ from aws_cdk import (
6
+ Fn,
7
+ Duration,
8
+ aws_apigateway as apigw,
9
+ aws_events as events,
10
+ aws_events_targets as targets,
11
+ aws_iam as iam,
12
+ aws_lambda as _lambda,
13
+ aws_lambda_event_sources as lambda_event_source,
14
+ aws_sqs as sqs,
15
+ aws_s3_assets as s3_assets,
16
+ )
17
+ from aws_cdk.aws_lambda import CfnFunction
18
+ from constructs import Construct
19
+
20
+ from wellapi.applications import Lambda, WellApi
21
+ from wellapi.build.packager import package
22
+ from wellapi.openapi.utils import get_openapi
23
+ from wellapi.utils import import_app, load_handlers
24
+
25
+ OPENAPI_FILE = "openapi-spec.json"
26
+ APP_LAYOUT_FILE = "app_content.zip"
27
+ DEP_LAYOUT_FILE = "layer_content.zip"
28
+
29
+
30
+ class WellApiCDK(Construct):
31
+ """
32
+ This class is used to create a Well API using AWS CDK.
33
+ """
34
+
35
+ def __init__(
36
+ self,
37
+ scope: Construct,
38
+ id_: str,
39
+ *,
40
+ app_srt: str,
41
+ handlers_dir: str,
42
+ ) -> None:
43
+ super().__init__(scope, id_)
44
+
45
+ self.app_srt = app_srt
46
+ self.handlers_dir = os.path.abspath(handlers_dir)
47
+
48
+ wellapi_app: WellApi = self._package_app()
49
+
50
+ # defining a Cfn Asset from the openAPI file
51
+ open_api_asset = s3_assets.Asset(self, "OpenApiAsset", path=OPENAPI_FILE)
52
+ transform_map = {"Location": open_api_asset.s3_object_url}
53
+ data = Fn.transform("AWS::Include", transform_map)
54
+
55
+ self.api = apigw.SpecRestApi(
56
+ self,
57
+ f"{wellapi_app.title}Api",
58
+ api_definition=apigw.ApiDefinition.from_inline(data),
59
+ )
60
+
61
+ for q in wellapi_app.queues:
62
+ queue = sqs.Queue(self, f"{q.queue_name}Queue", queue_name=q.queue_name)
63
+
64
+ shared_layer = [
65
+ _lambda.LayerVersion(
66
+ self,
67
+ "SharedLayer",
68
+ code=_lambda.Code.from_asset(DEP_LAYOUT_FILE),
69
+ compatible_runtimes=[_lambda.Runtime.PYTHON_3_12], # type: ignore
70
+ layer_version_name="shared_layer",
71
+ )
72
+ ]
73
+ code_layer = _lambda.Code.from_asset(APP_LAYOUT_FILE)
74
+
75
+ lmbd: Lambda
76
+ for lmbd in wellapi_app.lambdas:
77
+ lambda_function = _lambda.Function(
78
+ self,
79
+ f"{lmbd.arn}Function",
80
+ function_name=f"{lmbd.arn}Function",
81
+ runtime=_lambda.Runtime.PYTHON_3_12, # type: ignore
82
+ handler=lmbd.unique_id,
83
+ memory_size=lmbd.memory_size,
84
+ timeout=Duration.seconds(lmbd.timeout),
85
+ code=code_layer,
86
+ layers=shared_layer, # type: ignore
87
+ )
88
+
89
+ if lmbd.type_ == "endpoint":
90
+ lambda_function.add_permission(
91
+ f"{lmbd.arn}Permission",
92
+ principal=iam.ServicePrincipal("apigateway.amazonaws.com"),
93
+ action="lambda:InvokeFunction",
94
+ source_arn=self.api.arn_for_execute_api(lmbd.method.upper()),
95
+ )
96
+
97
+ cfn_lambda: CfnFunction = lambda_function.node.default_child # type: ignore
98
+ cfn_lambda.override_logical_id(f"{lmbd.arn}Function")
99
+ # self.api.node.add_dependency(lambda_function)
100
+
101
+ if lmbd.type_ == "queue":
102
+ queue = sqs.Queue(
103
+ self,
104
+ f"{lmbd.name}Queue",
105
+ queue_name=lmbd.path,
106
+ visibility_timeout=Duration.seconds(lmbd.timeout),
107
+ )
108
+
109
+ sqs_event_source = lambda_event_source.SqsEventSource(queue) # type: ignore
110
+
111
+ # Add SQS event source to the Lambda function
112
+ lambda_function.add_event_source(sqs_event_source)
113
+
114
+ if lmbd.type_ == "job":
115
+ rule = events.Rule(
116
+ self,
117
+ f"{lmbd.name}Rule",
118
+ schedule=events.Schedule.expression(lmbd.path),
119
+ )
120
+
121
+ rule.add_target(targets.LambdaFunction(lambda_function)) # type: ignore
122
+
123
+ def _package_app(self) -> WellApi:
124
+ wellapi_app = import_app(self.app_srt)
125
+ load_handlers(self.handlers_dir)
126
+
127
+ resp = get_openapi(
128
+ title=wellapi_app.title,
129
+ version=wellapi_app.version,
130
+ openapi_version="3.0.1",
131
+ description=wellapi_app.description,
132
+ lambdas=wellapi_app.lambdas,
133
+ tags=wellapi_app.openapi_tags,
134
+ servers=wellapi_app.servers,
135
+ )
136
+ with open(OPENAPI_FILE, "w") as f:
137
+ json.dump(resp, f)
138
+
139
+ package(DEP_LAYOUT_FILE, APP_LAYOUT_FILE)
140
+
141
+ return wellapi_app
@@ -0,0 +1,82 @@
1
+ import shutil
2
+ import subprocess
3
+ import zipfile
4
+ from pathlib import Path
5
+
6
+ """
7
+ https://docs.astral.sh/uv/guides/integration/aws-lambda/#deploying-a-zip-archive
8
+ """
9
+
10
+ EXPERT_DEP_FILE = [
11
+ "uv",
12
+ "export",
13
+ "--frozen",
14
+ "--no-dev",
15
+ "--no-editable",
16
+ "-orequirements.txt",
17
+ ]
18
+ INSTALL_DEP = [
19
+ "uv",
20
+ "pip",
21
+ "install",
22
+ "--no-installer-metadata",
23
+ "--no-compile-bytecode",
24
+ "--python-platform",
25
+ "x86_64-manylinux2014",
26
+ "--python",
27
+ "3.12",
28
+ "--prefix",
29
+ "packages",
30
+ "-r",
31
+ "requirements.txt",
32
+ ]
33
+
34
+
35
+ def install_dependencies():
36
+ # Експорт файлу requirements.txt
37
+ subprocess.run(EXPERT_DEP_FILE, check=True)
38
+
39
+ # Створення директорії для інсталяції
40
+ packages_path = Path("packages")
41
+ packages_path.mkdir(parents=True, exist_ok=True)
42
+
43
+ subprocess.run(INSTALL_DEP, check=True)
44
+
45
+
46
+ def copy_to_python_folder():
47
+ src = Path("packages/lib")
48
+ dst = Path("python/lib")
49
+ dst.parent.mkdir(exist_ok=True)
50
+ shutil.copytree(src, dst, dirs_exist_ok=True)
51
+
52
+
53
+ def create_zip(file_name: str, dir_name: str):
54
+ zip_path = Path(file_name)
55
+ with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as z:
56
+ for path in Path(dir_name).rglob("*"):
57
+ # TODO: remove this check
58
+ if str(path).startswith(".venv/"):
59
+ continue
60
+ z.write(path, path.relative_to(Path(dir_name).parent))
61
+
62
+
63
+ def package_dependencies(layer_name):
64
+ install_dependencies()
65
+ copy_to_python_folder()
66
+ create_zip(layer_name, "python")
67
+ # clean up
68
+ shutil.rmtree("packages")
69
+ shutil.rmtree("python")
70
+
71
+
72
+ def package_app(app_name):
73
+ create_zip(app_name, ".")
74
+
75
+
76
+ def package(layer_name: str, app_name: str):
77
+ """
78
+ Package the application into a zip file.
79
+ :return:
80
+ """
81
+ package_app(app_name)
82
+ package_dependencies(layer_name)
@@ -0,0 +1,10 @@
1
+ """
2
+ https://medium.com/@usamadar4777/aws-sam-template-open-api-3-0-1-integration-26e67375c4a0
3
+
4
+ https://aws.amazon.com/blogs/opensource/create-restful-apis-on-aws-with-openapi-specification-with-no-coding/
5
+
6
+ https://github.com/aws/aws-sam-cli/blob/develop/tests/integration/testdata/start_api/swagger-template.yaml
7
+
8
+ samcli.commands.build.command.do_cli
9
+
10
+ """
File without changes