sera-2 1.9.1__py3-none-any.whl → 1.11.2__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.
- sera/libs/api_helper.py +81 -0
- sera/libs/base_service.py +19 -3
- sera/libs/middlewares/__init__.py +0 -0
- sera/libs/middlewares/auth.py +73 -0
- sera/libs/middlewares/uscp.py +68 -0
- sera/make/make_python_api.py +121 -221
- sera/make/make_python_model.py +140 -14
- sera/make/make_typescript_model.py +60 -25
- sera/models/_datatype.py +17 -15
- sera/models/_parse.py +20 -16
- sera/models/_property.py +3 -0
- {sera_2-1.9.1.dist-info → sera_2-1.11.2.dist-info}/METADATA +1 -1
- {sera_2-1.9.1.dist-info → sera_2-1.11.2.dist-info}/RECORD +14 -11
- {sera_2-1.9.1.dist-info → sera_2-1.11.2.dist-info}/WHEEL +0 -0
sera/libs/api_helper.py
CHANGED
@@ -1,10 +1,22 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
3
|
import re
|
4
|
+
from typing import Collection, Generic, cast
|
4
5
|
|
5
6
|
from litestar import Request, status_codes
|
7
|
+
from litestar.connection import ASGIConnection
|
8
|
+
from litestar.dto import MsgspecDTO
|
9
|
+
from litestar.dto._backend import DTOBackend
|
10
|
+
from litestar.dto._codegen_backend import DTOCodegenBackend
|
11
|
+
from litestar.enums import RequestEncodingType
|
6
12
|
from litestar.exceptions import HTTPException
|
13
|
+
from litestar.serialization import decode_json, decode_msgpack
|
14
|
+
from litestar.typing import FieldDefinition
|
15
|
+
from msgspec import Struct
|
16
|
+
|
7
17
|
from sera.libs.base_service import Query, QueryOp
|
18
|
+
from sera.libs.middlewares.uscp import STATE_SYSTEM_CONTROLLED_PROP_KEY
|
19
|
+
from sera.typing import T
|
8
20
|
|
9
21
|
# for parsing field names and operations from query string
|
10
22
|
FIELD_REG = re.compile(r"(?P<name>[a-zA-Z_0-9]+)(?:\[(?P<op>[a-zA-Z0-9]+)\])?")
|
@@ -64,3 +76,72 @@ def parse_query(request: Request, fields: set[str], debug: bool) -> Query:
|
|
64
76
|
continue
|
65
77
|
|
66
78
|
return query
|
79
|
+
|
80
|
+
|
81
|
+
class SingleAutoUSCP(MsgspecDTO[T], Generic[T]):
|
82
|
+
"""Auto Update System Controlled Property DTO"""
|
83
|
+
|
84
|
+
@classmethod
|
85
|
+
def create_for_field_definition(
|
86
|
+
cls,
|
87
|
+
field_definition: FieldDefinition,
|
88
|
+
handler_id: str,
|
89
|
+
backend_cls: type[DTOBackend] | None = None,
|
90
|
+
) -> None:
|
91
|
+
assert backend_cls is None, "Custom backend not supported"
|
92
|
+
super().create_for_field_definition(
|
93
|
+
field_definition, handler_id, FixedDTOBackend
|
94
|
+
)
|
95
|
+
|
96
|
+
def decode_bytes(self, value: bytes):
|
97
|
+
"""Decode a byte string into an object"""
|
98
|
+
backend = self._dto_backends[self.asgi_connection.route_handler.handler_id][
|
99
|
+
"data_backend"
|
100
|
+
] # pyright: ignore
|
101
|
+
obj = backend.populate_data_from_raw(value, self.asgi_connection)
|
102
|
+
obj.update_system_controlled_props(
|
103
|
+
self.asgi_connection.scope["state"][STATE_SYSTEM_CONTROLLED_PROP_KEY]
|
104
|
+
)
|
105
|
+
return obj
|
106
|
+
|
107
|
+
|
108
|
+
class FixedDTOBackend(DTOCodegenBackend):
|
109
|
+
def parse_raw(
|
110
|
+
self, raw: bytes, asgi_connection: ASGIConnection
|
111
|
+
) -> Struct | Collection[Struct]:
|
112
|
+
"""Parse raw bytes into transfer model type.
|
113
|
+
|
114
|
+
Note: instead of decoding into self.annotation, which I encounter this error: https://github.com/litestar-org/litestar/issues/4181; we have to use self.model_type, which is the original type.
|
115
|
+
|
116
|
+
Args:
|
117
|
+
raw: bytes
|
118
|
+
asgi_connection: The current ASGI Connection
|
119
|
+
|
120
|
+
Returns:
|
121
|
+
The raw bytes parsed into transfer model type.
|
122
|
+
"""
|
123
|
+
request_encoding = RequestEncodingType.JSON
|
124
|
+
|
125
|
+
if (content_type := getattr(asgi_connection, "content_type", None)) and (
|
126
|
+
media_type := content_type[0]
|
127
|
+
):
|
128
|
+
request_encoding = media_type
|
129
|
+
|
130
|
+
type_decoders = asgi_connection.route_handler.resolve_type_decoders()
|
131
|
+
|
132
|
+
if request_encoding == RequestEncodingType.MESSAGEPACK:
|
133
|
+
result = decode_msgpack(
|
134
|
+
value=raw,
|
135
|
+
target_type=self.model_type,
|
136
|
+
type_decoders=type_decoders,
|
137
|
+
strict=False,
|
138
|
+
)
|
139
|
+
else:
|
140
|
+
result = decode_json(
|
141
|
+
value=raw,
|
142
|
+
target_type=self.model_type,
|
143
|
+
type_decoders=type_decoders,
|
144
|
+
strict=False,
|
145
|
+
)
|
146
|
+
|
147
|
+
return cast("Struct | Collection[Struct]", result)
|
sera/libs/base_service.py
CHANGED
@@ -4,12 +4,13 @@ from enum import Enum
|
|
4
4
|
from math import dist
|
5
5
|
from typing import Annotated, Any, Generic, NamedTuple, Optional, Sequence, TypeVar
|
6
6
|
|
7
|
+
from sqlalchemy import Result, Select, exists, func, select
|
8
|
+
from sqlalchemy.orm import Session, load_only
|
9
|
+
|
7
10
|
from sera.libs.base_orm import BaseORM
|
8
11
|
from sera.misc import assert_not_null
|
9
12
|
from sera.models import Class
|
10
13
|
from sera.typing import FieldName, T, doc
|
11
|
-
from sqlalchemy import Result, Select, exists, func, select
|
12
|
-
from sqlalchemy.orm import Session, load_only
|
13
14
|
|
14
15
|
|
15
16
|
class QueryOp(str, Enum):
|
@@ -42,12 +43,24 @@ class QueryResult(NamedTuple, Generic[R]):
|
|
42
43
|
|
43
44
|
class BaseService(Generic[ID, R]):
|
44
45
|
|
46
|
+
instance = None
|
47
|
+
|
45
48
|
def __init__(self, cls: Class, orm_cls: type[R]):
|
46
49
|
self.cls = cls
|
47
50
|
self.orm_cls = orm_cls
|
48
51
|
self.id_prop = assert_not_null(cls.get_id_property())
|
49
52
|
|
50
53
|
self._cls_id_prop = getattr(self.orm_cls, self.id_prop.name)
|
54
|
+
self.is_id_auto_increment = self.id_prop.db.is_auto_increment
|
55
|
+
|
56
|
+
@classmethod
|
57
|
+
def get_instance(cls):
|
58
|
+
"""Get the singleton instance of the service."""
|
59
|
+
if cls.instance is None:
|
60
|
+
# assume that the subclass overrides the __init__ method
|
61
|
+
# so that we don't need to pass the class and orm_cls
|
62
|
+
cls.instance = cls()
|
63
|
+
return cls.instance
|
51
64
|
|
52
65
|
def get(
|
53
66
|
self,
|
@@ -90,7 +103,7 @@ class BaseService(Generic[ID, R]):
|
|
90
103
|
|
91
104
|
cq = select(func.count()).select_from(q.subquery())
|
92
105
|
rq = q.limit(limit).offset(offset)
|
93
|
-
records = self._process_result(session.execute(
|
106
|
+
records = self._process_result(session.execute(rq)).scalars().all()
|
94
107
|
total = session.execute(cq).scalar_one()
|
95
108
|
return QueryResult(records, total)
|
96
109
|
|
@@ -108,6 +121,9 @@ class BaseService(Generic[ID, R]):
|
|
108
121
|
|
109
122
|
def create(self, record: R, session: Session) -> R:
|
110
123
|
"""Create a new record."""
|
124
|
+
if self.is_id_auto_increment:
|
125
|
+
setattr(record, self.id_prop.name, None)
|
126
|
+
|
111
127
|
session.add(record)
|
112
128
|
session.commit()
|
113
129
|
return record
|
File without changes
|
@@ -0,0 +1,73 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from datetime import datetime, timezone
|
4
|
+
from typing import Callable, Generator, Generic, Optional, Sequence, Type
|
5
|
+
|
6
|
+
from litestar import Request
|
7
|
+
from litestar.connection import ASGIConnection
|
8
|
+
from litestar.exceptions import NotAuthorizedException
|
9
|
+
from litestar.middleware import AbstractAuthenticationMiddleware, AuthenticationResult
|
10
|
+
from litestar.types import ASGIApp, Method, Scopes
|
11
|
+
from litestar.types.composite_types import Dependencies
|
12
|
+
from sqlalchemy import select
|
13
|
+
from sqlalchemy.orm import Session
|
14
|
+
|
15
|
+
from sera.typing import T
|
16
|
+
|
17
|
+
|
18
|
+
class AuthMiddleware(AbstractAuthenticationMiddleware, Generic[T]):
|
19
|
+
"""
|
20
|
+
Middleware to handle authentication for the API.
|
21
|
+
|
22
|
+
This middleware checks if the user is authenticated to access
|
23
|
+
the requested resource. If not, it raises an HTTPException with a 401 status code.
|
24
|
+
"""
|
25
|
+
|
26
|
+
def __init__(
|
27
|
+
self,
|
28
|
+
app: ASGIApp,
|
29
|
+
user_handler: Callable[[str], T],
|
30
|
+
exclude: str | list[str] | None = None,
|
31
|
+
exclude_from_auth_key: str = "exclude_from_auth",
|
32
|
+
exclude_http_methods: Sequence[Method] | None = None,
|
33
|
+
scopes: Scopes | None = None,
|
34
|
+
) -> None:
|
35
|
+
super().__init__(
|
36
|
+
app=app,
|
37
|
+
exclude=exclude,
|
38
|
+
exclude_from_auth_key=exclude_from_auth_key,
|
39
|
+
exclude_http_methods=exclude_http_methods,
|
40
|
+
scopes=scopes,
|
41
|
+
)
|
42
|
+
self.user_handler = user_handler
|
43
|
+
|
44
|
+
async def authenticate_request(
|
45
|
+
self, connection: ASGIConnection
|
46
|
+
) -> AuthenticationResult:
|
47
|
+
# do something here.
|
48
|
+
if "userid" not in connection.session:
|
49
|
+
raise NotAuthorizedException(
|
50
|
+
detail="Invalid credentials",
|
51
|
+
)
|
52
|
+
|
53
|
+
userid: str = connection.session["userid"]
|
54
|
+
expired_at = datetime.fromtimestamp(
|
55
|
+
connection.session.get("exp", 0), timezone.utc
|
56
|
+
)
|
57
|
+
if expired_at < datetime.now(timezone.utc):
|
58
|
+
raise NotAuthorizedException(
|
59
|
+
detail="Credentials expired",
|
60
|
+
)
|
61
|
+
|
62
|
+
user = self.user_handler(userid)
|
63
|
+
if user is None:
|
64
|
+
raise NotAuthorizedException(
|
65
|
+
detail="User not found",
|
66
|
+
)
|
67
|
+
|
68
|
+
return AuthenticationResult(user, None)
|
69
|
+
|
70
|
+
@staticmethod
|
71
|
+
def save(req: Request, user: T, expired_at: datetime) -> None:
|
72
|
+
req.session["userid"] = user.id
|
73
|
+
req.session["exp"] = expired_at.timestamp()
|
@@ -0,0 +1,68 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from typing import Callable
|
4
|
+
|
5
|
+
from litestar.connection.base import UserT
|
6
|
+
from litestar.middleware import AbstractMiddleware
|
7
|
+
from litestar.types import ASGIApp, Message, Receive, Scope, Scopes, Send
|
8
|
+
|
9
|
+
STATE_SYSTEM_CONTROLLED_PROP_KEY = "system_controlled_properties"
|
10
|
+
|
11
|
+
|
12
|
+
class USCPMiddleware(AbstractMiddleware):
|
13
|
+
"""
|
14
|
+
Middleware to update system-controlled properties in the request.
|
15
|
+
|
16
|
+
This middleware updates the `created_at`, `updated_at`, and `deleted_at` properties
|
17
|
+
of the request with the current timestamp. It is intended to be used in a Litestar
|
18
|
+
application.
|
19
|
+
"""
|
20
|
+
|
21
|
+
def __init__(
|
22
|
+
self,
|
23
|
+
app: ASGIApp,
|
24
|
+
get_system_controlled_props: Callable[[UserT], dict],
|
25
|
+
skip_update_system_controlled_props: Callable[[UserT], bool],
|
26
|
+
exclude: str | list[str] | None = None,
|
27
|
+
exclude_opt_key: str | None = None,
|
28
|
+
scopes: Scopes | None = None,
|
29
|
+
) -> None:
|
30
|
+
"""Initialize the middleware.
|
31
|
+
|
32
|
+
Args:
|
33
|
+
app: The ``next`` ASGI app to call.
|
34
|
+
exclude: A pattern or list of patterns to match against a request's path.
|
35
|
+
If a match is found, the middleware will be skipped.
|
36
|
+
exclude_opt_key: An identifier that is set in the route handler
|
37
|
+
``opt`` key which allows skipping the middleware.
|
38
|
+
scopes: ASGI scope types, should be a set including
|
39
|
+
either or both 'ScopeType.HTTP' and 'ScopeType.WEBSOCKET'.
|
40
|
+
"""
|
41
|
+
super().__init__(app, exclude, exclude_opt_key, scopes)
|
42
|
+
self.get_system_controlled_props = get_system_controlled_props
|
43
|
+
self.skip_update_system_controlled_props = skip_update_system_controlled_props
|
44
|
+
|
45
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
46
|
+
user = scope["user"]
|
47
|
+
if self.skip_update_system_controlled_props(user):
|
48
|
+
scope["state"][STATE_SYSTEM_CONTROLLED_PROP_KEY] = None
|
49
|
+
else:
|
50
|
+
scope["state"][STATE_SYSTEM_CONTROLLED_PROP_KEY] = (
|
51
|
+
self.get_system_controlled_props(user)
|
52
|
+
)
|
53
|
+
await self.app(scope, receive, send)
|
54
|
+
|
55
|
+
|
56
|
+
def get_scp_from_user(user: UserT, fields: dict[str, str]) -> dict:
|
57
|
+
"""Get system-controlled properties from the user.
|
58
|
+
|
59
|
+
Args:
|
60
|
+
user: The user object.
|
61
|
+
fields: A list of fields to include in the system-controlled properties.
|
62
|
+
|
63
|
+
Returns:
|
64
|
+
A dictionary containing the system-controlled properties.
|
65
|
+
"""
|
66
|
+
return {
|
67
|
+
data_field: getattr(user, db_field) for data_field, db_field in fields.items()
|
68
|
+
}
|