nlbone 0.4.0__py3-none-any.whl → 0.4.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.
- nlbone/adapters/auth/__init__.py +1 -1
- nlbone/adapters/auth/keycloak.py +3 -2
- nlbone/adapters/db/__init__.py +2 -2
- nlbone/adapters/db/sqlalchemy/__init__.py +4 -4
- nlbone/adapters/db/sqlalchemy/base.py +0 -2
- nlbone/adapters/db/sqlalchemy/engine.py +4 -3
- nlbone/adapters/db/sqlalchemy/query_builder.py +27 -11
- nlbone/adapters/db/sqlalchemy/repository.py +4 -2
- nlbone/adapters/db/sqlalchemy/schema.py +5 -5
- nlbone/adapters/db/sqlalchemy/uow.py +3 -2
- nlbone/adapters/http_clients/uploadchi.py +27 -11
- nlbone/adapters/http_clients/uploadchi_async.py +29 -7
- nlbone/adapters/messaging/event_bus.py +3 -0
- nlbone/config/logging.py +3 -8
- nlbone/config/settings.py +11 -22
- nlbone/container.py +6 -5
- nlbone/core/application/events.py +5 -1
- nlbone/core/application/use_case.py +3 -1
- nlbone/core/domain/base.py +4 -0
- nlbone/core/ports/__init__.py +3 -3
- nlbone/core/ports/auth.py +1 -0
- nlbone/core/ports/event_bus.py +3 -0
- nlbone/core/ports/files.py +26 -5
- nlbone/core/ports/repo.py +5 -2
- nlbone/core/ports/uow.py +3 -0
- nlbone/interfaces/api/dependencies/__init__.py +11 -3
- nlbone/interfaces/api/dependencies/async_auth.py +61 -0
- nlbone/interfaces/api/dependencies/auth.py +5 -3
- nlbone/interfaces/api/dependencies/db.py +4 -2
- nlbone/interfaces/api/dependencies/uow.py +2 -1
- nlbone/interfaces/api/exception_handlers.py +17 -15
- nlbone/interfaces/api/exceptions.py +1 -2
- nlbone/interfaces/api/middleware/__init__.py +2 -2
- nlbone/interfaces/api/middleware/access_log.py +12 -8
- nlbone/interfaces/api/middleware/add_request_context.py +55 -52
- nlbone/interfaces/api/middleware/authentication.py +4 -1
- nlbone/interfaces/api/pagination/__init__.py +4 -5
- nlbone/interfaces/api/pagination/offset_base.py +0 -2
- nlbone/interfaces/cli/init_db.py +3 -0
- nlbone/utils/context.py +14 -4
- nlbone/utils/time.py +1 -1
- {nlbone-0.4.0.dist-info → nlbone-0.4.1.dist-info}/METADATA +1 -9
- nlbone-0.4.1.dist-info/RECORD +72 -0
- nlbone-0.4.0.dist-info/RECORD +0 -71
- {nlbone-0.4.0.dist-info → nlbone-0.4.1.dist-info}/WHEEL +0 -0
- {nlbone-0.4.0.dist-info → nlbone-0.4.1.dist-info}/licenses/LICENSE +0 -0
nlbone/adapters/auth/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
from .keycloak import KeycloakAuthService
|
|
1
|
+
from .keycloak import KeycloakAuthService
|
nlbone/adapters/auth/keycloak.py
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
from keycloak import KeycloakOpenID
|
|
2
2
|
from keycloak.exceptions import KeycloakAuthenticationError
|
|
3
|
-
|
|
3
|
+
|
|
4
4
|
from nlbone.config.settings import Settings, get_settings
|
|
5
|
+
from nlbone.core.ports.auth import AuthService
|
|
5
6
|
|
|
6
7
|
|
|
7
8
|
class KeycloakAuthService(AuthService):
|
|
@@ -70,4 +71,4 @@ class KeycloakAuthService(AuthService):
|
|
|
70
71
|
def client_has_access(self, token: str, permissions: list[str], allowed_clients: set[str] | None = None) -> bool:
|
|
71
72
|
if not self.is_client_token(token, allowed_clients):
|
|
72
73
|
return False
|
|
73
|
-
return self.has_access(token, permissions)
|
|
74
|
+
return self.has_access(token, permissions)
|
nlbone/adapters/db/__init__.py
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
from .sqlalchemy
|
|
2
|
-
from .sqlalchemy import get_paginated_response, apply_pagination
|
|
1
|
+
from .sqlalchemy import apply_pagination, get_paginated_response
|
|
3
2
|
from .sqlalchemy.base import Base
|
|
3
|
+
from .sqlalchemy.engine import async_ping, async_session, init_async_engine, init_sync_engine, sync_ping, sync_session
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from .
|
|
2
|
-
from .
|
|
3
|
-
from .repository import
|
|
4
|
-
from .uow import
|
|
1
|
+
from .engine import async_ping, async_session, init_async_engine, init_sync_engine, sync_ping, sync_session
|
|
2
|
+
from .query_builder import apply_pagination, get_paginated_response
|
|
3
|
+
from .repository import AsyncSqlAlchemyRepository, SqlAlchemyRepository
|
|
4
|
+
from .uow import AsyncSqlAlchemyUnitOfWork, SqlAlchemyUnitOfWork
|
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
from contextlib import asynccontextmanager, contextmanager
|
|
2
|
-
from typing import
|
|
2
|
+
from typing import Any, AsyncGenerator, Generator, Optional
|
|
3
3
|
|
|
4
4
|
from sqlalchemy import create_engine, text
|
|
5
5
|
from sqlalchemy.engine import Engine
|
|
6
|
-
from sqlalchemy.orm import Session, sessionmaker
|
|
7
|
-
|
|
8
6
|
from sqlalchemy.ext.asyncio import (
|
|
9
7
|
AsyncEngine,
|
|
10
8
|
AsyncSession,
|
|
11
9
|
async_sessionmaker,
|
|
12
10
|
create_async_engine,
|
|
13
11
|
)
|
|
12
|
+
from sqlalchemy.orm import Session, sessionmaker
|
|
14
13
|
|
|
15
14
|
from nlbone.config.settings import get_settings
|
|
16
15
|
|
|
@@ -117,12 +116,14 @@ def sync_ping() -> None:
|
|
|
117
116
|
with eng.connect() as conn:
|
|
118
117
|
conn.execute(text("SELECT 1"))
|
|
119
118
|
|
|
119
|
+
|
|
120
120
|
def get_async_session_factory() -> async_sessionmaker[AsyncSession]:
|
|
121
121
|
if _async_session_factory is None:
|
|
122
122
|
init_async_engine()
|
|
123
123
|
assert _async_session_factory is not None
|
|
124
124
|
return _async_session_factory
|
|
125
125
|
|
|
126
|
+
|
|
126
127
|
def get_sync_session_factory() -> sessionmaker[Session]:
|
|
127
128
|
if _sync_session_factory is None:
|
|
128
129
|
init_sync_engine()
|
|
@@ -1,12 +1,22 @@
|
|
|
1
|
-
from typing import
|
|
1
|
+
from typing import Any, Callable, Optional, Sequence, Type, Union
|
|
2
2
|
|
|
3
3
|
from sqlalchemy import asc, desc, or_
|
|
4
|
+
from sqlalchemy.dialects.postgresql import ENUM as PGEnum
|
|
5
|
+
from sqlalchemy.orm import Query, Session
|
|
4
6
|
from sqlalchemy.orm.interfaces import LoaderOption
|
|
5
7
|
from sqlalchemy.sql.sqltypes import (
|
|
6
|
-
|
|
8
|
+
BigInteger,
|
|
9
|
+
Boolean,
|
|
10
|
+
Float,
|
|
11
|
+
Integer,
|
|
12
|
+
Numeric,
|
|
13
|
+
SmallInteger,
|
|
14
|
+
String,
|
|
15
|
+
Text,
|
|
16
|
+
)
|
|
17
|
+
from sqlalchemy.sql.sqltypes import (
|
|
18
|
+
Enum as SAEnum,
|
|
7
19
|
)
|
|
8
|
-
from sqlalchemy.orm import Session, Query
|
|
9
|
-
from sqlalchemy.dialects.postgresql import ENUM as PGEnum
|
|
10
20
|
|
|
11
21
|
from nlbone.interfaces.api.exceptions import UnprocessableEntityException
|
|
12
22
|
from nlbone.interfaces.api.pagination import PaginateRequest, PaginateResponse
|
|
@@ -122,12 +132,16 @@ def _apply_filters(pagination, entity, query):
|
|
|
122
132
|
return float(v)
|
|
123
133
|
# Booleans
|
|
124
134
|
if isinstance(coltype, Boolean):
|
|
125
|
-
if isinstance(v, bool):
|
|
126
|
-
|
|
135
|
+
if isinstance(v, bool):
|
|
136
|
+
return v
|
|
137
|
+
if isinstance(v, (int, float)):
|
|
138
|
+
return bool(v)
|
|
127
139
|
if isinstance(v, str):
|
|
128
140
|
vl = v.strip().lower()
|
|
129
|
-
if vl in {"true", "1", "yes", "y", "t"}:
|
|
130
|
-
|
|
141
|
+
if vl in {"true", "1", "yes", "y", "t"}:
|
|
142
|
+
return True
|
|
143
|
+
if vl in {"false", "0", "no", "n", "f"}:
|
|
144
|
+
return False
|
|
131
145
|
return None
|
|
132
146
|
# fallback
|
|
133
147
|
return v
|
|
@@ -215,7 +229,8 @@ def _serialize_item(item: Any, output_cls: OutputType) -> Any:
|
|
|
215
229
|
try:
|
|
216
230
|
obj = output_cls(item) # type: ignore[call-arg]
|
|
217
231
|
try:
|
|
218
|
-
from dataclasses import
|
|
232
|
+
from dataclasses import asdict, is_dataclass
|
|
233
|
+
|
|
219
234
|
if is_dataclass(obj):
|
|
220
235
|
return asdict(obj)
|
|
221
236
|
except Exception:
|
|
@@ -252,5 +267,6 @@ def get_paginated_response(
|
|
|
252
267
|
data = [output_cls.model_validate(r, from_attributes=True).model_dump() for r in rows]
|
|
253
268
|
else:
|
|
254
269
|
data = rows
|
|
255
|
-
return PaginateResponse(
|
|
256
|
-
|
|
270
|
+
return PaginateResponse(
|
|
271
|
+
total_count=total_count, data=data, limit=pagination.limit, offset=pagination.offset
|
|
272
|
+
).to_dict()
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
-
|
|
2
|
+
|
|
3
|
+
from typing import Generic, Iterable, List, Optional, Type, TypeVar
|
|
3
4
|
|
|
4
5
|
from sqlalchemy import select
|
|
5
6
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
6
7
|
from sqlalchemy.orm import Session
|
|
7
|
-
|
|
8
|
+
|
|
9
|
+
from nlbone.core.ports.repo import AsyncRepository, Repository
|
|
8
10
|
|
|
9
11
|
T = TypeVar("T")
|
|
10
12
|
|
|
@@ -1,18 +1,17 @@
|
|
|
1
|
-
|
|
2
1
|
import importlib
|
|
3
2
|
from typing import Sequence
|
|
4
3
|
|
|
5
4
|
from nlbone.adapters.db.sqlalchemy.base import Base
|
|
6
5
|
from nlbone.adapters.db.sqlalchemy.engine import init_async_engine, init_sync_engine
|
|
7
6
|
|
|
7
|
+
DEFAULT_MODEL_MODULES: Sequence[str] = ()
|
|
8
8
|
|
|
9
|
-
DEFAULT_MODEL_MODULES: Sequence[str] = (
|
|
10
|
-
)
|
|
11
9
|
|
|
12
10
|
def import_model_modules(modules: Sequence[str] | None = None) -> None:
|
|
13
|
-
for m in
|
|
11
|
+
for m in modules or DEFAULT_MODEL_MODULES:
|
|
14
12
|
importlib.import_module(m)
|
|
15
13
|
|
|
14
|
+
|
|
16
15
|
# --------- Async (SQLAlchemy 2.x) ----------
|
|
17
16
|
async def init_db_async(model_modules: Sequence[str] | None = None) -> None:
|
|
18
17
|
"""Create tables using AsyncEngine (dev/test). Prefer Alembic in prod."""
|
|
@@ -21,9 +20,10 @@ async def init_db_async(model_modules: Sequence[str] | None = None) -> None:
|
|
|
21
20
|
async with engine.begin() as conn:
|
|
22
21
|
await conn.run_sync(Base.metadata.create_all)
|
|
23
22
|
|
|
23
|
+
|
|
24
24
|
# --------- Sync ----------
|
|
25
25
|
def init_db_sync(model_modules: Sequence[str] | None = None) -> None:
|
|
26
26
|
"""Create tables using Sync Engine (dev/test). Prefer Alembic in prod."""
|
|
27
27
|
import_model_modules(model_modules)
|
|
28
28
|
engine = init_sync_engine()
|
|
29
|
-
Base.metadata.create_all(bind=engine)
|
|
29
|
+
Base.metadata.create_all(bind=engine)
|
|
@@ -2,10 +2,11 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from typing import Optional
|
|
4
4
|
|
|
5
|
-
from sqlalchemy.ext.asyncio import
|
|
5
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
6
6
|
from sqlalchemy.orm import Session, sessionmaker
|
|
7
|
-
|
|
7
|
+
|
|
8
8
|
from nlbone.core.ports.uow import AsyncUnitOfWork as AsyncUnitOfWorkPort
|
|
9
|
+
from nlbone.core.ports.uow import UnitOfWork
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
class SqlAlchemyUnitOfWork(UnitOfWork):
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
+
|
|
2
3
|
import json
|
|
3
4
|
from typing import Any, Optional
|
|
4
5
|
from urllib.parse import urlparse, urlunparse
|
|
@@ -6,8 +7,8 @@ from urllib.parse import urlparse, urlunparse
|
|
|
6
7
|
import httpx
|
|
7
8
|
import requests
|
|
8
9
|
|
|
9
|
-
from nlbone.core.ports.files import FileServicePort
|
|
10
10
|
from nlbone.config.settings import get_settings
|
|
11
|
+
from nlbone.core.ports.files import FileServicePort
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
class UploadchiError(RuntimeError):
|
|
@@ -28,8 +29,9 @@ def _auth_headers(token: str | None) -> dict[str, str]:
|
|
|
28
29
|
return {"Authorization": f"Bearer {token}"} if token else {}
|
|
29
30
|
|
|
30
31
|
|
|
31
|
-
def _build_list_query(
|
|
32
|
-
|
|
32
|
+
def _build_list_query(
|
|
33
|
+
limit: int, offset: int, filters: dict[str, Any] | None, sort: list[tuple[str, str]] | None
|
|
34
|
+
) -> dict[str, Any]:
|
|
33
35
|
q: dict[str, Any] = {"limit": limit, "offset": offset}
|
|
34
36
|
if filters:
|
|
35
37
|
q["filters"] = json.dumps(filters)
|
|
@@ -55,8 +57,12 @@ def _normalize_https_base(url: str) -> str:
|
|
|
55
57
|
|
|
56
58
|
|
|
57
59
|
class UploadchiClient(FileServicePort):
|
|
58
|
-
def __init__(
|
|
59
|
-
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
base_url: Optional[str] = None,
|
|
63
|
+
timeout_seconds: Optional[float] = None,
|
|
64
|
+
client: httpx.Client | None = None,
|
|
65
|
+
) -> None:
|
|
60
66
|
s = get_settings()
|
|
61
67
|
self._base_url = _normalize_https_base(base_url or str(s.UPLOADCHI_BASE_URL))
|
|
62
68
|
self._timeout = timeout_seconds or float(s.HTTP_TIMEOUT_SECONDS)
|
|
@@ -65,8 +71,9 @@ class UploadchiClient(FileServicePort):
|
|
|
65
71
|
def close(self) -> None:
|
|
66
72
|
self._client.close()
|
|
67
73
|
|
|
68
|
-
def upload_file(
|
|
69
|
-
|
|
74
|
+
def upload_file(
|
|
75
|
+
self, file_bytes: bytes, filename: str, params: dict[str, Any] | None = None, token: str | None = None
|
|
76
|
+
) -> dict:
|
|
70
77
|
tok = _resolve_token(token)
|
|
71
78
|
files = {"file": (filename, file_bytes)}
|
|
72
79
|
data = (params or {}).copy()
|
|
@@ -77,13 +84,22 @@ class UploadchiClient(FileServicePort):
|
|
|
77
84
|
|
|
78
85
|
def commit_file(self, file_id: int, client_id: str, token: str | None = None) -> None:
|
|
79
86
|
tok = _resolve_token(token)
|
|
80
|
-
r = self._client.post(
|
|
81
|
-
|
|
87
|
+
r = self._client.post(
|
|
88
|
+
f"{self._base_url}/{file_id}/commit",
|
|
89
|
+
headers=_auth_headers(tok),
|
|
90
|
+
params={"client_id": client_id} if client_id else None,
|
|
91
|
+
)
|
|
82
92
|
if r.status_code not in (204, 200):
|
|
83
93
|
raise UploadchiError(r.status_code, r.text)
|
|
84
94
|
|
|
85
|
-
def list_files(
|
|
86
|
-
|
|
95
|
+
def list_files(
|
|
96
|
+
self,
|
|
97
|
+
limit: int = 10,
|
|
98
|
+
offset: int = 0,
|
|
99
|
+
filters: dict[str, Any] | None = None,
|
|
100
|
+
sort: list[tuple[str, str]] | None = None,
|
|
101
|
+
token: str | None = None,
|
|
102
|
+
) -> dict:
|
|
87
103
|
tok = _resolve_token(token)
|
|
88
104
|
q = _build_list_query(limit, offset, filters, sort)
|
|
89
105
|
r = self._client.get(self._base_url, params=q, headers=_auth_headers(tok))
|
|
@@ -1,22 +1,35 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
-
|
|
2
|
+
|
|
3
|
+
from typing import Any, AsyncIterator, Optional
|
|
4
|
+
|
|
3
5
|
import httpx
|
|
4
6
|
|
|
5
|
-
from nlbone.core.ports.files import AsyncFileServicePort
|
|
6
7
|
from nlbone.config.settings import get_settings
|
|
8
|
+
from nlbone.core.ports.files import AsyncFileServicePort
|
|
9
|
+
|
|
7
10
|
from .uploadchi import UploadchiError, _auth_headers, _build_list_query, _filename_from_cd, _resolve_token
|
|
8
11
|
|
|
12
|
+
|
|
9
13
|
class UploadchiAsyncClient(AsyncFileServicePort):
|
|
10
|
-
def __init__(
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
base_url: Optional[str] = None,
|
|
17
|
+
timeout_seconds: Optional[float] = None,
|
|
18
|
+
client: httpx.AsyncClient | None = None,
|
|
19
|
+
) -> None:
|
|
11
20
|
s = get_settings()
|
|
12
21
|
self._base_url = base_url or str(s.UPLOADCHI_BASE_URL)
|
|
13
22
|
self._timeout = timeout_seconds or float(s.HTTP_TIMEOUT_SECONDS)
|
|
14
|
-
self._client = client or httpx.AsyncClient(
|
|
23
|
+
self._client = client or httpx.AsyncClient(
|
|
24
|
+
base_url=self._base_url, timeout=self._timeout, follow_redirects=True
|
|
25
|
+
)
|
|
15
26
|
|
|
16
27
|
async def aclose(self) -> None:
|
|
17
28
|
await self._client.aclose()
|
|
18
29
|
|
|
19
|
-
async def upload_file(
|
|
30
|
+
async def upload_file(
|
|
31
|
+
self, file_bytes: bytes, filename: str, params: dict[str, Any] | None = None, token: str | None = None
|
|
32
|
+
) -> dict:
|
|
20
33
|
tok = _resolve_token(token)
|
|
21
34
|
files = {"file": (filename, file_bytes)}
|
|
22
35
|
data = (params or {}).copy()
|
|
@@ -27,11 +40,20 @@ class UploadchiAsyncClient(AsyncFileServicePort):
|
|
|
27
40
|
|
|
28
41
|
async def commit_file(self, file_id: int, client_id: str, token: str | None = None) -> None:
|
|
29
42
|
tok = _resolve_token(token)
|
|
30
|
-
r = await self._client.post(
|
|
43
|
+
r = await self._client.post(
|
|
44
|
+
f"/{file_id}/commit", headers=_auth_headers(tok), params={"client_id": client_id} if client_id else None
|
|
45
|
+
)
|
|
31
46
|
if r.status_code not in (204, 200):
|
|
32
47
|
raise UploadchiError(r.status_code, await r.aread())
|
|
33
48
|
|
|
34
|
-
async def list_files(
|
|
49
|
+
async def list_files(
|
|
50
|
+
self,
|
|
51
|
+
limit: int = 10,
|
|
52
|
+
offset: int = 0,
|
|
53
|
+
filters: dict[str, Any] | None = None,
|
|
54
|
+
sort: list[tuple[str, str]] | None = None,
|
|
55
|
+
token: str | None = None,
|
|
56
|
+
) -> dict:
|
|
35
57
|
tok = _resolve_token(token)
|
|
36
58
|
q = _build_list_query(limit, offset, filters, sort)
|
|
37
59
|
r = await self._client.get("", params=q, headers=_auth_headers(tok))
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
+
|
|
2
3
|
from collections import defaultdict
|
|
3
4
|
from typing import Callable, Dict, Iterable, List
|
|
5
|
+
|
|
4
6
|
from nlbone.core.domain.base import DomainEvent
|
|
5
7
|
from nlbone.core.ports.event_bus import EventBusPort
|
|
6
8
|
|
|
9
|
+
|
|
7
10
|
class InMemoryEventBus(EventBusPort):
|
|
8
11
|
def __init__(self) -> None:
|
|
9
12
|
self._handlers: Dict[str, List[Callable[[DomainEvent], None]]] = defaultdict(list)
|
nlbone/config/logging.py
CHANGED
|
@@ -1,19 +1,16 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import contextvars
|
|
3
4
|
import json
|
|
4
5
|
import logging
|
|
5
6
|
import sys
|
|
6
7
|
from datetime import datetime, timezone
|
|
7
8
|
from typing import Any, MutableMapping, Optional
|
|
8
9
|
|
|
9
|
-
import contextvars
|
|
10
|
-
|
|
11
10
|
from nlbone.config.settings import get_settings
|
|
12
11
|
|
|
13
12
|
# Context variable for request/correlation id
|
|
14
|
-
_request_id_var: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar(
|
|
15
|
-
"request_id", default=None
|
|
16
|
-
)
|
|
13
|
+
_request_id_var: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar("request_id", default=None)
|
|
17
14
|
|
|
18
15
|
|
|
19
16
|
def set_request_id(request_id: Optional[str]) -> None:
|
|
@@ -97,9 +94,7 @@ def _build_stream_handler(json_enabled: bool, level: int) -> logging.Handler:
|
|
|
97
94
|
handler.setFormatter(JsonFormatter())
|
|
98
95
|
else:
|
|
99
96
|
# human-friendly text format
|
|
100
|
-
fmt = (
|
|
101
|
-
"%(asctime)s | %(levelname)s | %(name)s | rid=%(request_id)s | %(message)s"
|
|
102
|
-
)
|
|
97
|
+
fmt = "%(asctime)s | %(levelname)s | %(name)s | rid=%(request_id)s | %(message)s"
|
|
103
98
|
datefmt = "%Y-%m-%dT%H:%M:%S%z"
|
|
104
99
|
handler.setFormatter(logging.Formatter(fmt=fmt, datefmt=datefmt))
|
|
105
100
|
return handler
|
nlbone/config/settings.py
CHANGED
|
@@ -2,7 +2,8 @@ import os
|
|
|
2
2
|
from functools import lru_cache
|
|
3
3
|
from pathlib import Path
|
|
4
4
|
from typing import Literal
|
|
5
|
-
|
|
5
|
+
|
|
6
|
+
from pydantic import AnyHttpUrl, Field, SecretStr
|
|
6
7
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
7
8
|
|
|
8
9
|
|
|
@@ -34,9 +35,7 @@ class Settings(BaseSettings):
|
|
|
34
35
|
# App
|
|
35
36
|
# ---------------------------
|
|
36
37
|
PORT: int = 8000
|
|
37
|
-
ENV: Literal["local", "dev", "staging", "prod"] = Field(default="local"
|
|
38
|
-
validation_alias=AliasChoices("NLBONE_ENV", "ENV",
|
|
39
|
-
"ENVIRONMENT"))
|
|
38
|
+
ENV: Literal["local", "dev", "staging", "prod"] = Field(default="local")
|
|
40
39
|
DEBUG: bool = Field(default=False)
|
|
41
40
|
LOG_LEVEL: Literal["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"] = Field(default="INFO")
|
|
42
41
|
LOG_JSON: bool = Field(default=True)
|
|
@@ -49,23 +48,15 @@ class Settings(BaseSettings):
|
|
|
49
48
|
# ---------------------------
|
|
50
49
|
# Keycloak / Auth
|
|
51
50
|
# ---------------------------
|
|
52
|
-
KEYCLOAK_SERVER_URL: AnyHttpUrl = Field(default="https://keycloak.local/auth"
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
validation_alias=AliasChoices("NLBONE_KEYCLOAK_REALM_NAME", "KEYCLOAK_REALM_NAME"))
|
|
57
|
-
KEYCLOAK_CLIENT_ID: str = Field(default="nlbone",
|
|
58
|
-
validation_alias=AliasChoices("NLBONE_KEYCLOAK_CLIENT_ID", "KEYCLOAK_CLIENT_ID"))
|
|
59
|
-
KEYCLOAK_CLIENT_SECRET: SecretStr = Field(default=SecretStr("dev-secret"),
|
|
60
|
-
validation_alias=AliasChoices("NLBONE_KEYCLOAK_CLIENT_SECRET",
|
|
61
|
-
"KEYCLOAK_CLIENT_SECRET"))
|
|
51
|
+
KEYCLOAK_SERVER_URL: AnyHttpUrl = Field(default="https://keycloak.local/auth")
|
|
52
|
+
KEYCLOAK_REALM_NAME: str = Field(default="numberland")
|
|
53
|
+
KEYCLOAK_CLIENT_ID: str = Field(default="nlbone")
|
|
54
|
+
KEYCLOAK_CLIENT_SECRET: SecretStr = Field(default=SecretStr("dev-secret"))
|
|
62
55
|
|
|
63
56
|
# ---------------------------
|
|
64
57
|
# Database
|
|
65
58
|
# ---------------------------
|
|
66
|
-
POSTGRES_DB_DSN: str = Field(default="postgresql+asyncpg://user:pass@localhost:5432/nlbone"
|
|
67
|
-
validation_alias=AliasChoices("NLBONE_POSTGRES_DB_DSN",
|
|
68
|
-
"POSTGRES_DB_DSN", "DATABASE_URL", "DB_DSN"))
|
|
59
|
+
POSTGRES_DB_DSN: str = Field(default="postgresql+asyncpg://user:pass@localhost:5432/nlbone")
|
|
69
60
|
DB_ECHO: bool = Field(default=False)
|
|
70
61
|
DB_POOL_SIZE: int = Field(default=5)
|
|
71
62
|
DB_MAX_OVERFLOW: int = Field(default=10)
|
|
@@ -74,6 +65,7 @@ class Settings(BaseSettings):
|
|
|
74
65
|
# Messaging / Cache
|
|
75
66
|
# ---------------------------
|
|
76
67
|
REDIS_URL: str = Field(default="redis://localhost:6379/0")
|
|
68
|
+
|
|
77
69
|
# --- Event bus / Outbox ---
|
|
78
70
|
EVENT_BUS_BACKEND: Literal["inmemory"] = Field(default="inmemory")
|
|
79
71
|
OUTBOX_ENABLED: bool = Field(default=False)
|
|
@@ -83,13 +75,10 @@ class Settings(BaseSettings):
|
|
|
83
75
|
# UPLOADCHI
|
|
84
76
|
# ---------------------------
|
|
85
77
|
UPLOADCHI_BASE_URL: AnyHttpUrl = Field(default="https://uploadchi.numberland.ir/v1/files")
|
|
86
|
-
UPLOADCHI_TOKEN: SecretStr
|
|
87
|
-
default=None,
|
|
88
|
-
validation_alias=AliasChoices("NLBONE_UPLOADCHI_TOKEN", "UPLOADCHI_TOKEN"),
|
|
89
|
-
)
|
|
78
|
+
UPLOADCHI_TOKEN: SecretStr = Field(default="")
|
|
90
79
|
|
|
91
80
|
model_config = SettingsConfigDict(
|
|
92
|
-
env_prefix="
|
|
81
|
+
env_prefix="",
|
|
93
82
|
env_file=None,
|
|
94
83
|
env_file_encoding="utf-8",
|
|
95
84
|
extra="ignore",
|
nlbone/container.py
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
+
|
|
2
3
|
from typing import Any, Mapping, Optional
|
|
3
4
|
|
|
4
5
|
from dependency_injector import containers, providers
|
|
5
6
|
|
|
6
|
-
from nlbone.adapters.
|
|
7
|
-
from nlbone.adapters.db.sqlalchemy
|
|
7
|
+
from nlbone.adapters.auth.keycloak import KeycloakAuthService
|
|
8
|
+
from nlbone.adapters.db.sqlalchemy import AsyncSqlAlchemyUnitOfWork, SqlAlchemyUnitOfWork
|
|
9
|
+
from nlbone.adapters.db.sqlalchemy.engine import get_async_session_factory, get_sync_session_factory
|
|
8
10
|
from nlbone.adapters.http_clients.uploadchi import UploadchiClient
|
|
9
11
|
from nlbone.adapters.http_clients.uploadchi_async import UploadchiAsyncClient
|
|
10
|
-
from nlbone.adapters.auth.keycloak import KeycloakAuthService
|
|
11
12
|
from nlbone.adapters.messaging import InMemoryEventBus
|
|
12
13
|
from nlbone.core.ports import EventBusPort
|
|
13
|
-
from nlbone.core.ports.files import
|
|
14
|
+
from nlbone.core.ports.files import AsyncFileServicePort, FileServicePort
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
class Container(containers.DeclarativeContainer):
|
|
@@ -21,7 +22,7 @@ class Container(containers.DeclarativeContainer):
|
|
|
21
22
|
|
|
22
23
|
# --- UoW ---
|
|
23
24
|
uow = providers.Factory(SqlAlchemyUnitOfWork, session_factory=sync_session_factory)
|
|
24
|
-
async_uow = providers.Factory(
|
|
25
|
+
async_uow = providers.Factory(AsyncSqlAlchemyUnitOfWork, session_factory=async_session_factory)
|
|
25
26
|
|
|
26
27
|
# --- Event bus ---
|
|
27
28
|
event_bus: providers.Singleton[EventBusPort] = providers.Singleton(InMemoryEventBus)
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
from typing import Iterable, Sequence
|
|
2
|
+
|
|
2
3
|
from nlbone.core.domain.base import AggregateRoot, DomainEvent
|
|
3
4
|
from nlbone.core.ports.event_bus import EventBusPort
|
|
4
5
|
|
|
6
|
+
|
|
5
7
|
def collect_events(*aggregates: Iterable[AggregateRoot]) -> list[DomainEvent]:
|
|
6
8
|
events: list[DomainEvent] = []
|
|
7
9
|
for agg in aggregates:
|
|
@@ -12,5 +14,7 @@ def collect_events(*aggregates: Iterable[AggregateRoot]) -> list[DomainEvent]:
|
|
|
12
14
|
events.extend(a.pull_events())
|
|
13
15
|
return events
|
|
14
16
|
|
|
17
|
+
|
|
15
18
|
def publish_events(bus: EventBusPort, events: Sequence[DomainEvent]) -> None:
|
|
16
|
-
if events:
|
|
19
|
+
if events:
|
|
20
|
+
bus.publish(events)
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
from typing import Protocol, TypeVar
|
|
2
2
|
|
|
3
|
+
InT = TypeVar("InT")
|
|
4
|
+
OutT = TypeVar("OutT")
|
|
3
5
|
|
|
4
|
-
InT = TypeVar("InT"); OutT = TypeVar("OutT")
|
|
5
6
|
|
|
6
7
|
class UseCase(Protocol):
|
|
7
8
|
def __call__(self, command: InT) -> OutT: ...
|
|
8
9
|
|
|
10
|
+
|
|
9
11
|
class AsyncUseCase(Protocol):
|
|
10
12
|
async def __call__(self, command: InT) -> OutT: ...
|
nlbone/core/domain/base.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
+
|
|
2
3
|
from dataclasses import dataclass
|
|
3
4
|
from datetime import datetime, timezone
|
|
4
5
|
from typing import Any, Generic, List, TypeVar
|
|
@@ -13,6 +14,7 @@ class DomainError(Exception):
|
|
|
13
14
|
@dataclass(frozen=True)
|
|
14
15
|
class DomainEvent:
|
|
15
16
|
"""Immutable domain event."""
|
|
17
|
+
|
|
16
18
|
occurred_at: datetime = datetime.now(timezone.utc)
|
|
17
19
|
|
|
18
20
|
@property
|
|
@@ -22,6 +24,7 @@ class DomainEvent:
|
|
|
22
24
|
|
|
23
25
|
class ValueObject:
|
|
24
26
|
"""Base for value objects (immutable in practice)."""
|
|
27
|
+
|
|
25
28
|
def __eq__(self, other: Any) -> bool:
|
|
26
29
|
return isinstance(other, self.__class__) and self.__dict__ == other.__dict__
|
|
27
30
|
|
|
@@ -35,6 +38,7 @@ class Entity(Generic[TId]):
|
|
|
35
38
|
|
|
36
39
|
class AggregateRoot(Entity[TId]):
|
|
37
40
|
"""Aggregate root with domain event collection."""
|
|
41
|
+
|
|
38
42
|
def __init__(self, *args, **kwargs) -> None:
|
|
39
43
|
self._domain_events: List[DomainEvent] = []
|
|
40
44
|
|
nlbone/core/ports/__init__.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
from .auth import AuthService
|
|
2
|
-
from .repo import Repository, AsyncRepository
|
|
3
|
-
from .files import FileServicePort, AsyncFileServicePort
|
|
4
|
-
from .uow import UnitOfWork, AsyncUnitOfWork
|
|
5
2
|
from .event_bus import EventBusPort
|
|
3
|
+
from .files import AsyncFileServicePort, FileServicePort
|
|
4
|
+
from .repo import AsyncRepository, Repository
|
|
5
|
+
from .uow import AsyncUnitOfWork, UnitOfWork
|
nlbone/core/ports/auth.py
CHANGED
nlbone/core/ports/event_bus.py
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
+
|
|
2
3
|
from typing import Callable, Iterable, Protocol
|
|
4
|
+
|
|
3
5
|
from nlbone.core.domain.base import DomainEvent
|
|
4
6
|
|
|
7
|
+
|
|
5
8
|
class EventBusPort(Protocol):
|
|
6
9
|
def publish(self, events: Iterable[DomainEvent]) -> None: ...
|
|
7
10
|
def subscribe(self, event_name: str, handler: Callable[[DomainEvent], None]) -> None: ...
|
nlbone/core/ports/files.py
CHANGED
|
@@ -1,20 +1,41 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
-
|
|
2
|
+
|
|
3
|
+
from typing import Any, AsyncIterator, Protocol, runtime_checkable
|
|
4
|
+
|
|
3
5
|
|
|
4
6
|
@runtime_checkable
|
|
5
7
|
class FileServicePort(Protocol):
|
|
6
|
-
def upload_file(
|
|
8
|
+
def upload_file(
|
|
9
|
+
self, file_bytes: bytes, filename: str, params: dict[str, Any] | None = None, token: str | None = None
|
|
10
|
+
) -> dict: ...
|
|
7
11
|
def commit_file(self, file_id: int, client_id: str, token: str | None = None) -> None: ...
|
|
8
|
-
def list_files(
|
|
12
|
+
def list_files(
|
|
13
|
+
self,
|
|
14
|
+
limit: int = 10,
|
|
15
|
+
offset: int = 0,
|
|
16
|
+
filters: dict[str, Any] | None = None,
|
|
17
|
+
sort: list[tuple[str, str]] | None = None,
|
|
18
|
+
token: str | None = None,
|
|
19
|
+
) -> dict: ...
|
|
9
20
|
def get_file(self, file_id: int, token: str | None = None) -> dict: ...
|
|
10
21
|
def download_file(self, file_id: int, token: str | None = None) -> tuple[bytes, str, str]: ...
|
|
11
22
|
def delete_file(self, file_id: int, token: str | None = None) -> None: ...
|
|
12
23
|
|
|
24
|
+
|
|
13
25
|
@runtime_checkable
|
|
14
26
|
class AsyncFileServicePort(Protocol):
|
|
15
|
-
async def upload_file(
|
|
27
|
+
async def upload_file(
|
|
28
|
+
self, file_bytes: bytes, filename: str, params: dict[str, Any] | None = None, token: str | None = None
|
|
29
|
+
) -> dict: ...
|
|
16
30
|
async def commit_file(self, file_id: int, client_id: str, token: str | None = None) -> None: ...
|
|
17
|
-
async def list_files(
|
|
31
|
+
async def list_files(
|
|
32
|
+
self,
|
|
33
|
+
limit: int = 10,
|
|
34
|
+
offset: int = 0,
|
|
35
|
+
filters: dict[str, Any] | None = None,
|
|
36
|
+
sort: list[tuple[str, str]] | None = None,
|
|
37
|
+
token: str | None = None,
|
|
38
|
+
) -> dict: ...
|
|
18
39
|
async def get_file(self, file_id: int, token: str | None = None) -> dict: ...
|
|
19
40
|
async def download_file(self, file_id: int, token: str | None = None) -> tuple[AsyncIterator[bytes], str, str]: ...
|
|
20
41
|
async def delete_file(self, file_id: int, token: str | None = None) -> None: ...
|