nlbone 0.2.0__tar.gz → 0.3.1__tar.gz
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-0.2.0 → nlbone-0.3.1}/PKG-INFO +1 -1
- {nlbone-0.2.0 → nlbone-0.3.1}/pyproject.toml +1 -1
- {nlbone-0.2.0 → nlbone-0.3.1}/src/nlbone/adapters/auth/keycloak.py +12 -4
- {nlbone-0.2.0 → nlbone-0.3.1}/src/nlbone/adapters/db/__init__.py +2 -1
- nlbone-0.3.1/src/nlbone/adapters/db/sqlalchemy/__init__.py +1 -0
- {nlbone-0.2.0/src/nlbone/adapters/db → nlbone-0.3.1/src/nlbone/adapters/db/sqlalchemy}/query_builder.py +73 -6
- {nlbone-0.2.0 → nlbone-0.3.1}/src/nlbone/interfaces/api/dependencies/auth.py +17 -6
- nlbone-0.3.1/src/nlbone/interfaces/api/exception_handlers.py +107 -0
- nlbone-0.3.1/src/nlbone/interfaces/api/exceptions.py +81 -0
- nlbone-0.3.1/src/nlbone/interfaces/api/pagination/__init__.py +20 -0
- nlbone-0.2.0/src/nlbone/adapters/db/sqlalchemy/__init__.py +0 -4
- nlbone-0.2.0/src/nlbone/adapters/db/sqlalchemy/query/__init__.py +0 -3
- nlbone-0.2.0/src/nlbone/adapters/db/sqlalchemy/query/builder.py +0 -21
- nlbone-0.2.0/src/nlbone/adapters/db/sqlalchemy/query/coercion.py +0 -64
- nlbone-0.2.0/src/nlbone/adapters/db/sqlalchemy/query/filters.py +0 -57
- nlbone-0.2.0/src/nlbone/adapters/db/sqlalchemy/query/ordering.py +0 -14
- nlbone-0.2.0/src/nlbone/adapters/db/sqlalchemy/query/types.py +0 -11
- nlbone-0.2.0/src/nlbone/interfaces/api/exceptions.py +0 -67
- nlbone-0.2.0/src/nlbone/interfaces/api/pagination/__init__.py +0 -9
- {nlbone-0.2.0 → nlbone-0.3.1}/.gitignore +0 -0
- {nlbone-0.2.0 → nlbone-0.3.1}/LICENSE +0 -0
- {nlbone-0.2.0 → nlbone-0.3.1}/README.md +0 -0
- {nlbone-0.2.0 → nlbone-0.3.1}/src/nlbone/__init__.py +0 -0
- {nlbone-0.2.0 → nlbone-0.3.1}/src/nlbone/adapters/__init__.py +0 -0
- {nlbone-0.2.0 → nlbone-0.3.1}/src/nlbone/adapters/auth/__init__.py +0 -0
- {nlbone-0.2.0 → nlbone-0.3.1}/src/nlbone/adapters/db/memory.py +0 -0
- {nlbone-0.2.0 → nlbone-0.3.1}/src/nlbone/adapters/db/postgres.py +0 -0
- {nlbone-0.2.0 → nlbone-0.3.1}/src/nlbone/adapters/db/sqlalchemy/base.py +0 -0
- {nlbone-0.2.0 → nlbone-0.3.1}/src/nlbone/adapters/db/sqlalchemy/engine.py +0 -0
- {nlbone-0.2.0 → nlbone-0.3.1}/src/nlbone/adapters/db/sqlalchemy/schema.py +0 -0
- {nlbone-0.2.0 → nlbone-0.3.1}/src/nlbone/adapters/http_clients/__init__.py +0 -0
- {nlbone-0.2.0 → nlbone-0.3.1}/src/nlbone/adapters/http_clients/email_gateway.py +0 -0
- {nlbone-0.2.0 → nlbone-0.3.1}/src/nlbone/adapters/http_clients/uploadchi.py +0 -0
- {nlbone-0.2.0 → nlbone-0.3.1}/src/nlbone/adapters/http_clients/uploadchi_async.py +0 -0
- {nlbone-0.2.0 → nlbone-0.3.1}/src/nlbone/adapters/messaging/__init__.py +0 -0
- {nlbone-0.2.0 → nlbone-0.3.1}/src/nlbone/adapters/messaging/redis.py +0 -0
- {nlbone-0.2.0 → nlbone-0.3.1}/src/nlbone/config/__init__.py +0 -0
- {nlbone-0.2.0 → nlbone-0.3.1}/src/nlbone/config/logging.py +0 -0
- {nlbone-0.2.0 → nlbone-0.3.1}/src/nlbone/config/settings.py +0 -0
- {nlbone-0.2.0 → nlbone-0.3.1}/src/nlbone/container.py +0 -0
- {nlbone-0.2.0 → nlbone-0.3.1}/src/nlbone/core/__init__.py +0 -0
- {nlbone-0.2.0 → nlbone-0.3.1}/src/nlbone/core/application/__init__.py +0 -0
- {nlbone-0.2.0 → nlbone-0.3.1}/src/nlbone/core/application/services/__init__.py +0 -0
- {nlbone-0.2.0 → nlbone-0.3.1}/src/nlbone/core/application/services.py +0 -0
- {nlbone-0.2.0 → nlbone-0.3.1}/src/nlbone/core/application/use_cases/__init__.py +0 -0
- {nlbone-0.2.0 → nlbone-0.3.1}/src/nlbone/core/application/use_cases/register_user.py +0 -0
- {nlbone-0.2.0 → nlbone-0.3.1}/src/nlbone/core/domain/__init__.py +0 -0
- {nlbone-0.2.0 → nlbone-0.3.1}/src/nlbone/core/domain/events.py +0 -0
- {nlbone-0.2.0 → nlbone-0.3.1}/src/nlbone/core/domain/models.py +0 -0
- {nlbone-0.2.0 → nlbone-0.3.1}/src/nlbone/core/ports/__init__.py +0 -0
- {nlbone-0.2.0 → nlbone-0.3.1}/src/nlbone/core/ports/auth.py +0 -0
- {nlbone-0.2.0 → nlbone-0.3.1}/src/nlbone/core/ports/files.py +0 -0
- {nlbone-0.2.0 → nlbone-0.3.1}/src/nlbone/core/ports/messaging.py +0 -0
- {nlbone-0.2.0 → nlbone-0.3.1}/src/nlbone/core/ports/repo.py +0 -0
- {nlbone-0.2.0 → nlbone-0.3.1}/src/nlbone/interfaces/__init__.py +0 -0
- {nlbone-0.2.0 → nlbone-0.3.1}/src/nlbone/interfaces/api/__init__.py +0 -0
- {nlbone-0.2.0 → nlbone-0.3.1}/src/nlbone/interfaces/api/dependencies/__init__.py +0 -0
- {nlbone-0.2.0 → nlbone-0.3.1}/src/nlbone/interfaces/api/dependencies/db.py +0 -0
- {nlbone-0.2.0 → nlbone-0.3.1}/src/nlbone/interfaces/api/middleware/__init__.py +0 -0
- {nlbone-0.2.0 → nlbone-0.3.1}/src/nlbone/interfaces/api/middleware/access_log.py +0 -0
- {nlbone-0.2.0 → nlbone-0.3.1}/src/nlbone/interfaces/api/middleware/add_request_context.py +0 -0
- {nlbone-0.2.0 → nlbone-0.3.1}/src/nlbone/interfaces/api/middleware/authentication.py +0 -0
- {nlbone-0.2.0 → nlbone-0.3.1}/src/nlbone/interfaces/api/pagination/offset_base.py +0 -0
- {nlbone-0.2.0 → nlbone-0.3.1}/src/nlbone/interfaces/api/routers.py +0 -0
- {nlbone-0.2.0 → nlbone-0.3.1}/src/nlbone/interfaces/api/schemas.py +0 -0
- {nlbone-0.2.0 → nlbone-0.3.1}/src/nlbone/interfaces/cli/__init__.py +0 -0
- {nlbone-0.2.0 → nlbone-0.3.1}/src/nlbone/interfaces/cli/init_db.py +0 -0
- {nlbone-0.2.0 → nlbone-0.3.1}/src/nlbone/interfaces/cli/main.py +0 -0
- {nlbone-0.2.0 → nlbone-0.3.1}/src/nlbone/interfaces/jobs/__init__.py +0 -0
- {nlbone-0.2.0 → nlbone-0.3.1}/src/nlbone/interfaces/jobs/sync_tokens.py +0 -0
- {nlbone-0.2.0 → nlbone-0.3.1}/src/nlbone/types.py +0 -0
- {nlbone-0.2.0 → nlbone-0.3.1}/src/nlbone/utils/__init__.py +0 -0
- {nlbone-0.2.0 → nlbone-0.3.1}/src/nlbone/utils/context.py +0 -0
- {nlbone-0.2.0 → nlbone-0.3.1}/src/nlbone/utils/time.py +0 -0
|
@@ -43,15 +43,23 @@ class KeycloakAuthService(AuthService):
|
|
|
43
43
|
print(f"Failed to get client token: {e}")
|
|
44
44
|
return None
|
|
45
45
|
|
|
46
|
-
def
|
|
46
|
+
def get_client_id(self, token: str):
|
|
47
47
|
data = self.verify_token(token)
|
|
48
48
|
if not data:
|
|
49
|
-
return
|
|
49
|
+
return None
|
|
50
50
|
|
|
51
|
-
is_service_account = bool(data.get("username").startswith(
|
|
51
|
+
is_service_account = bool(data.get("username").startswith("service-account-"))
|
|
52
52
|
client_id = data.get("client_id")
|
|
53
53
|
|
|
54
54
|
if not is_service_account or not client_id:
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
return client_id
|
|
58
|
+
|
|
59
|
+
def is_client_token(self, token: str, allowed_clients: set[str] | None = None) -> bool:
|
|
60
|
+
client_id = self.get_client_id(token)
|
|
61
|
+
|
|
62
|
+
if not client_id:
|
|
55
63
|
return False
|
|
56
64
|
|
|
57
65
|
if allowed_clients is not None and client_id not in allowed_clients:
|
|
@@ -62,4 +70,4 @@ class KeycloakAuthService(AuthService):
|
|
|
62
70
|
def client_has_access(self, token: str, permissions: list[str], allowed_clients: set[str] | None = None) -> bool:
|
|
63
71
|
if not self.is_client_token(token, allowed_clients):
|
|
64
72
|
return False
|
|
65
|
-
return self.has_access(token, permissions)
|
|
73
|
+
return self.has_access(token, permissions)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .query_builder import get_paginated_response, apply_pagination
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
from typing import Union, Callable, Any, Optional, Type, Sequence
|
|
2
|
+
|
|
1
3
|
from sqlalchemy import asc, desc, or_
|
|
4
|
+
from sqlalchemy.orm.interfaces import LoaderOption
|
|
2
5
|
from sqlalchemy.sql.sqltypes import (
|
|
3
6
|
String, Text, Integer, BigInteger, SmallInteger, Numeric, Float, Boolean, Enum as SAEnum
|
|
4
7
|
)
|
|
@@ -170,8 +173,9 @@ def _apply_filters(pagination, entity, query):
|
|
|
170
173
|
return query
|
|
171
174
|
|
|
172
175
|
|
|
173
|
-
def apply_pagination(pagination: PaginateRequest, entity, session: Session, limit=True) -> Query:
|
|
174
|
-
|
|
176
|
+
def apply_pagination(pagination: PaginateRequest, entity, session: Session, limit=True, query=None) -> Query:
|
|
177
|
+
if not query:
|
|
178
|
+
query = session.query(entity)
|
|
175
179
|
query = _apply_filters(pagination, entity, query)
|
|
176
180
|
query = _apply_order(pagination, entity, query)
|
|
177
181
|
if limit:
|
|
@@ -179,11 +183,74 @@ def apply_pagination(pagination: PaginateRequest, entity, session: Session, limi
|
|
|
179
183
|
return query
|
|
180
184
|
|
|
181
185
|
|
|
182
|
-
|
|
183
|
-
|
|
186
|
+
OutputType = Union[type, Callable[[Any], Any], None]
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _serialize_item(item: Any, output_cls: OutputType) -> Any:
|
|
190
|
+
"""Serialize a single ORM item based on output_cls (Pydantic v1/v2 or custom mapper)."""
|
|
191
|
+
if output_cls is None:
|
|
192
|
+
return item
|
|
193
|
+
|
|
194
|
+
if callable(output_cls) and not isinstance(output_cls, type):
|
|
195
|
+
return output_cls(item)
|
|
196
|
+
|
|
197
|
+
if hasattr(output_cls, "model_validate"):
|
|
198
|
+
try:
|
|
199
|
+
model = output_cls.model_validate(item, from_attributes=True) # type: ignore[attr-defined]
|
|
200
|
+
if hasattr(model, "model_dump"):
|
|
201
|
+
return model.model_dump() # type: ignore[attr-defined]
|
|
202
|
+
return model
|
|
203
|
+
except Exception:
|
|
204
|
+
pass
|
|
205
|
+
|
|
206
|
+
if hasattr(output_cls, "from_orm"):
|
|
207
|
+
try:
|
|
208
|
+
model = output_cls.from_orm(item) # type: ignore[attr-defined]
|
|
209
|
+
if hasattr(model, "dict"):
|
|
210
|
+
return model.dict() # type: ignore[attr-defined]
|
|
211
|
+
return model
|
|
212
|
+
except Exception:
|
|
213
|
+
pass
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
obj = output_cls(item) # type: ignore[call-arg]
|
|
217
|
+
try:
|
|
218
|
+
from dataclasses import is_dataclass, asdict
|
|
219
|
+
if is_dataclass(obj):
|
|
220
|
+
return asdict(obj)
|
|
221
|
+
except Exception:
|
|
222
|
+
pass
|
|
223
|
+
return obj
|
|
224
|
+
except Exception:
|
|
225
|
+
return item
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def get_paginated_response(
|
|
229
|
+
pagination,
|
|
230
|
+
entity,
|
|
231
|
+
session: Session,
|
|
232
|
+
*,
|
|
233
|
+
with_count: bool = True,
|
|
234
|
+
output_cls: Optional[Type] = None,
|
|
235
|
+
eager_options: Optional[Sequence[LoaderOption]] = None,
|
|
236
|
+
) -> dict:
|
|
237
|
+
# پایهی کوئری
|
|
238
|
+
query = session.query(entity)
|
|
239
|
+
if eager_options:
|
|
240
|
+
query = query.options(*eager_options)
|
|
241
|
+
|
|
242
|
+
query = apply_pagination(pagination, entity, session, not with_count, query=query)
|
|
243
|
+
|
|
184
244
|
total_count = None
|
|
185
245
|
if with_count:
|
|
186
246
|
total_count = query.count()
|
|
187
247
|
query = query.limit(pagination.limit).offset(pagination.offset)
|
|
188
|
-
|
|
189
|
-
|
|
248
|
+
|
|
249
|
+
rows = query.all()
|
|
250
|
+
|
|
251
|
+
if output_cls is not None:
|
|
252
|
+
data = [output_cls.model_validate(r, from_attributes=True).model_dump() for r in rows]
|
|
253
|
+
else:
|
|
254
|
+
data = rows
|
|
255
|
+
return PaginateResponse(total_count=total_count, data=data, limit=pagination.limit,
|
|
256
|
+
offset=pagination.offset).to_dict()
|
|
@@ -5,6 +5,20 @@ from nlbone.interfaces.api.exceptions import ForbiddenException, UnauthorizedExc
|
|
|
5
5
|
from nlbone.utils.context import current_request
|
|
6
6
|
|
|
7
7
|
|
|
8
|
+
def current_user_id() -> int:
|
|
9
|
+
user_id = current_request().state.user_id
|
|
10
|
+
if user_id is not None:
|
|
11
|
+
return int(user_id)
|
|
12
|
+
raise UnauthorizedException()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def current_client_id() -> str:
|
|
16
|
+
request = current_request()
|
|
17
|
+
if client_id := KeycloakAuthService().get_client_id(request.state.token):
|
|
18
|
+
return str(client_id)
|
|
19
|
+
raise UnauthorizedException()
|
|
20
|
+
|
|
21
|
+
|
|
8
22
|
def client_has_access(*, permissions=None):
|
|
9
23
|
def decorator(func):
|
|
10
24
|
@functools.wraps(func)
|
|
@@ -19,12 +33,10 @@ def client_has_access(*, permissions=None):
|
|
|
19
33
|
return decorator
|
|
20
34
|
|
|
21
35
|
|
|
22
|
-
|
|
23
36
|
def user_authenticated(func):
|
|
24
37
|
@functools.wraps(func)
|
|
25
38
|
async def wrapper(*args, **kwargs):
|
|
26
|
-
|
|
27
|
-
if not request.state.user_id:
|
|
39
|
+
if not current_user_id():
|
|
28
40
|
raise UnauthorizedException()
|
|
29
41
|
return await func(*args, **kwargs)
|
|
30
42
|
|
|
@@ -36,7 +48,7 @@ def has_access(*, permissions=None):
|
|
|
36
48
|
@functools.wraps(func)
|
|
37
49
|
def wrapper(*args, **kwargs):
|
|
38
50
|
request = current_request()
|
|
39
|
-
if not
|
|
51
|
+
if not current_user_id():
|
|
40
52
|
raise UnauthorizedException()
|
|
41
53
|
if not KeycloakAuthService().has_access(request.state.token, permissions=permissions):
|
|
42
54
|
raise ForbiddenException(f"Forbidden {permissions}")
|
|
@@ -44,5 +56,4 @@ def has_access(*, permissions=None):
|
|
|
44
56
|
return func(*args, **kwargs)
|
|
45
57
|
|
|
46
58
|
return wrapper
|
|
47
|
-
return decorator
|
|
48
|
-
|
|
59
|
+
return decorator
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Any, Optional, Mapping
|
|
3
|
+
from uuid import uuid4
|
|
4
|
+
|
|
5
|
+
from fastapi import FastAPI, Request
|
|
6
|
+
from fastapi.responses import JSONResponse
|
|
7
|
+
from fastapi.exceptions import RequestValidationError
|
|
8
|
+
from pydantic import ValidationError
|
|
9
|
+
from fastapi import HTTPException as FastAPIHTTPException
|
|
10
|
+
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
11
|
+
|
|
12
|
+
from .exceptions import BaseHttpException
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# ---- Helpers ---------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
def _ensure_trace_id(request: Request) -> str:
|
|
18
|
+
rid = request.headers.get("X-Request-Id") or request.headers.get("X-Trace-Id")
|
|
19
|
+
return rid or str(uuid4())
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _json_response(
|
|
23
|
+
request: Request,
|
|
24
|
+
status_code: int,
|
|
25
|
+
*,
|
|
26
|
+
detail: Any,
|
|
27
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
28
|
+
trace_id: Optional[str] = None,
|
|
29
|
+
extra: Optional[Mapping[str, Any]] = None,
|
|
30
|
+
) -> JSONResponse:
|
|
31
|
+
payload: dict[str, Any] = {"detail": detail}
|
|
32
|
+
if extra:
|
|
33
|
+
payload.update(extra)
|
|
34
|
+
tid = trace_id or _ensure_trace_id(request)
|
|
35
|
+
|
|
36
|
+
payload.setdefault("trace_id", tid)
|
|
37
|
+
|
|
38
|
+
base_headers = {"X-Trace-Id": tid}
|
|
39
|
+
if headers:
|
|
40
|
+
base_headers.update(headers)
|
|
41
|
+
|
|
42
|
+
return JSONResponse(status_code=status_code, content=payload, headers=base_headers)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ---- Public Installer ------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
def install_exception_handlers(
|
|
48
|
+
app: FastAPI,
|
|
49
|
+
*,
|
|
50
|
+
logger: Any = None,
|
|
51
|
+
expose_server_errors: bool = False,
|
|
52
|
+
) -> None:
|
|
53
|
+
@app.exception_handler(BaseHttpException)
|
|
54
|
+
async def _handle_base_http_exception(request: Request, exc: BaseHttpException):
|
|
55
|
+
if logger:
|
|
56
|
+
logger.warning(
|
|
57
|
+
"http_error",
|
|
58
|
+
extra={"status": exc.status_code, "detail": exc.detail, "path": request.url.path},
|
|
59
|
+
)
|
|
60
|
+
return _json_response(request, exc.status_code, detail=exc.detail)
|
|
61
|
+
|
|
62
|
+
@app.exception_handler(FastAPIHTTPException)
|
|
63
|
+
async def _handle_fastapi_http_exception(request: Request, exc: FastAPIHTTPException):
|
|
64
|
+
if logger:
|
|
65
|
+
logger.warning(
|
|
66
|
+
"fastapi_http_error",
|
|
67
|
+
extra={"status": exc.status_code, "detail": exc.detail, "path": request.url.path},
|
|
68
|
+
)
|
|
69
|
+
return _json_response(request, exc.status_code, detail=exc.detail)
|
|
70
|
+
|
|
71
|
+
@app.exception_handler(StarletteHTTPException)
|
|
72
|
+
async def _handle_starlette_http_exception(request: Request, exc: StarletteHTTPException):
|
|
73
|
+
if logger:
|
|
74
|
+
logger.warning(
|
|
75
|
+
"starlette_http_error",
|
|
76
|
+
extra={"status": exc.status_code, "detail": exc.detail, "path": request.url.path},
|
|
77
|
+
)
|
|
78
|
+
return _json_response(request, exc.status_code, detail=exc.detail)
|
|
79
|
+
|
|
80
|
+
# 3) خطاهای اعتبارسنجی FastAPI (request body/query/path)
|
|
81
|
+
@app.exception_handler(RequestValidationError)
|
|
82
|
+
async def _handle_request_validation_error(request: Request, exc: RequestValidationError):
|
|
83
|
+
errors = exc.errors()
|
|
84
|
+
if logger:
|
|
85
|
+
logger.info(
|
|
86
|
+
"request_validation_error",
|
|
87
|
+
extra={"errors": errors, "path": request.url.path},
|
|
88
|
+
)
|
|
89
|
+
return _json_response(request, 422, detail=errors)
|
|
90
|
+
|
|
91
|
+
@app.exception_handler(ValidationError)
|
|
92
|
+
async def _handle_pydantic_validation_error(request: Request, exc: ValidationError):
|
|
93
|
+
errors = exc.errors()
|
|
94
|
+
if logger:
|
|
95
|
+
logger.info(
|
|
96
|
+
"pydantic_validation_error",
|
|
97
|
+
extra={"errors": errors, "path": request.url.path},
|
|
98
|
+
)
|
|
99
|
+
return _json_response(request, 422, detail=errors)
|
|
100
|
+
|
|
101
|
+
@app.exception_handler(Exception)
|
|
102
|
+
async def _handle_unexpected_exception(request: Request, exc: Exception):
|
|
103
|
+
tid = _ensure_trace_id(request)
|
|
104
|
+
if logger:
|
|
105
|
+
logger.exception("unhandled_exception", extra={"trace_id": tid, "path": request.url.path})
|
|
106
|
+
detail = str(exc) if expose_server_errors else "internal server error"
|
|
107
|
+
return _json_response(request, 500, detail=detail, trace_id=tid)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from typing import Any, Iterable
|
|
2
|
+
from fastapi import HTTPException, status
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def _error_entry(loc: Iterable[Any] | None, detail: str, type_: str) -> dict:
|
|
6
|
+
return {
|
|
7
|
+
"loc": list(loc) if loc else [],
|
|
8
|
+
"msg": detail,
|
|
9
|
+
"type": type_,
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _errors(loc: Iterable[Any] | None, detail: str, type_: str) -> list[dict]:
|
|
14
|
+
return [_error_entry(loc, detail, type_)]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BaseHttpException(HTTPException):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class BadRequestException(BaseHttpException):
|
|
22
|
+
def __init__(self, detail: str):
|
|
23
|
+
super().__init__(
|
|
24
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
25
|
+
detail=detail,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class UnauthorizedException(BaseHttpException):
|
|
30
|
+
def __init__(self, detail: str = "unauthorized"):
|
|
31
|
+
super().__init__(
|
|
32
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
33
|
+
detail=detail,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ForbiddenException(BaseHttpException):
|
|
38
|
+
def __init__(self, detail: str = "forbidden"):
|
|
39
|
+
super().__init__(
|
|
40
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
41
|
+
detail=detail,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class NotFoundException(BaseHttpException):
|
|
46
|
+
def __init__(self, detail: str = "not found"):
|
|
47
|
+
super().__init__(
|
|
48
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
49
|
+
detail=detail,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ConflictException(BaseHttpException):
|
|
54
|
+
def __init__(self, detail: str = "conflict"):
|
|
55
|
+
super().__init__(
|
|
56
|
+
status_code=status.HTTP_409_CONFLICT,
|
|
57
|
+
detail=detail,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class NotSupportedException(BaseHttpException):
|
|
62
|
+
def __init__(self, detail: str = "NotSupported"):
|
|
63
|
+
super().__init__(
|
|
64
|
+
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
|
65
|
+
detail=detail,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class UnprocessableEntityException(BaseHttpException):
|
|
70
|
+
def __init__(self, detail: str, loc: Iterable[Any] | None = None, type_: str = "unprocessable_entity"):
|
|
71
|
+
super().__init__(
|
|
72
|
+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
73
|
+
detail=_errors(loc, detail, type_),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class LogicalValidationException(UnprocessableEntityException):
|
|
78
|
+
def __init__(self, detail: str, loc: Iterable[Any] | None = None, type_: str = "logical_error"):
|
|
79
|
+
super().__init__(detail=detail, loc=loc, type_=type_)
|
|
80
|
+
|
|
81
|
+
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from typing import Generic, List, Optional, TypeVar
|
|
2
|
+
from pydantic import BaseModel
|
|
3
|
+
from fastapi import Depends
|
|
4
|
+
|
|
5
|
+
from .offset_base import PaginateResponse, PaginateRequest
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_pagination(
|
|
9
|
+
req: PaginateRequest = Depends(PaginateRequest)
|
|
10
|
+
) -> PaginateRequest:
|
|
11
|
+
return req
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
T = TypeVar("T")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Paginated(BaseModel, Generic[T]):
|
|
18
|
+
total_count: Optional[int]
|
|
19
|
+
total_page: Optional[int]
|
|
20
|
+
data: List[T]
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
from sqlalchemy.orm import Session, Query
|
|
3
|
-
|
|
4
|
-
from nlbone.adapters.db.sqlalchemy.query.filters import apply_filters_to_query
|
|
5
|
-
from nlbone.adapters.db.sqlalchemy.query.ordering import apply_order_to_query
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def apply_pagination(pagination, entity, session: Session, limit: bool = True) -> Query:
|
|
9
|
-
q: Query = session.query(entity)
|
|
10
|
-
q = apply_filters_to_query(pagination, entity, q)
|
|
11
|
-
from nlbone.adapters.db.sqlalchemy.query.ordering import apply_order_to_query
|
|
12
|
-
q = apply_order_to_query(pagination, entity, q)
|
|
13
|
-
if limit:
|
|
14
|
-
q = q.limit(pagination.limit).offset(pagination.offset)
|
|
15
|
-
return q
|
|
16
|
-
|
|
17
|
-
def apply_filters(pagination, entity, query: Query) -> Query:
|
|
18
|
-
return apply_filters_to_query(pagination, entity, query)
|
|
19
|
-
|
|
20
|
-
def apply_order(pagination, entity, query: Query) -> Query:
|
|
21
|
-
return apply_order_to_query(pagination, entity, query)
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
from sqlalchemy.sql.sqltypes import (
|
|
2
|
-
String, Text, Integer, BigInteger, SmallInteger, Numeric, Float, Boolean, Enum as SAEnum
|
|
3
|
-
)
|
|
4
|
-
try:
|
|
5
|
-
from sqlalchemy.dialects.postgresql import ENUM as PGEnum
|
|
6
|
-
except Exception:
|
|
7
|
-
PGEnum = type("PGEnum", (), {})
|
|
8
|
-
|
|
9
|
-
from .types import InvalidEnum
|
|
10
|
-
|
|
11
|
-
def is_text_type(coltype) -> bool:
|
|
12
|
-
return isinstance(coltype, (String, Text))
|
|
13
|
-
|
|
14
|
-
def looks_like_wildcard(s: str) -> bool:
|
|
15
|
-
return isinstance(s, str) and ("*" in s or "%" in s)
|
|
16
|
-
|
|
17
|
-
def to_like_pattern(s: str) -> str:
|
|
18
|
-
s = (s or "")
|
|
19
|
-
s = s.replace("*", "%")
|
|
20
|
-
return s if "%" in s else f"%{s}%"
|
|
21
|
-
|
|
22
|
-
def _coerce_enum(col_type, raw):
|
|
23
|
-
if raw is None:
|
|
24
|
-
return None
|
|
25
|
-
enum_cls = getattr(col_type, "enum_class", None)
|
|
26
|
-
if enum_cls is not None:
|
|
27
|
-
if isinstance(raw, enum_cls):
|
|
28
|
-
return raw
|
|
29
|
-
if isinstance(raw, str):
|
|
30
|
-
low = raw.strip().lower()
|
|
31
|
-
for m in enum_cls:
|
|
32
|
-
if m.name.lower() == low or str(m.value).lower() == low:
|
|
33
|
-
return m
|
|
34
|
-
raise InvalidEnum(f"'{raw}' is not one of {[m.name for m in enum_cls]}")
|
|
35
|
-
choices = list(getattr(col_type, "enums", []) or [])
|
|
36
|
-
if isinstance(raw, str):
|
|
37
|
-
low = raw.strip().lower()
|
|
38
|
-
for c in choices:
|
|
39
|
-
if c.lower() == low:
|
|
40
|
-
return c
|
|
41
|
-
raise InvalidEnum(f"'{raw}' is not one of {choices or '[no choices defined]'}")
|
|
42
|
-
|
|
43
|
-
def coerce_value_for_column(coltype, v):
|
|
44
|
-
if v is None:
|
|
45
|
-
return None
|
|
46
|
-
if isinstance(coltype, (SAEnum, PGEnum)):
|
|
47
|
-
return _coerce_enum(coltype, v)
|
|
48
|
-
if is_text_type(coltype):
|
|
49
|
-
return str(v)
|
|
50
|
-
if isinstance(coltype, (Integer, BigInteger, SmallInteger)):
|
|
51
|
-
return int(v)
|
|
52
|
-
if isinstance(coltype, (Float, Numeric)):
|
|
53
|
-
return float(v)
|
|
54
|
-
if isinstance(coltype, Boolean):
|
|
55
|
-
if isinstance(v, bool):
|
|
56
|
-
return v
|
|
57
|
-
if isinstance(v, (int, float)):
|
|
58
|
-
return bool(v)
|
|
59
|
-
if isinstance(v, str):
|
|
60
|
-
vl = v.strip().lower()
|
|
61
|
-
if vl in {"true", "1", "yes", "y", "t"}: return True
|
|
62
|
-
if vl in {"false", "0", "no", "n", "f"}: return False
|
|
63
|
-
return None
|
|
64
|
-
return v
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
from sqlalchemy import or_
|
|
3
|
-
from sqlalchemy.orm import Query
|
|
4
|
-
|
|
5
|
-
try:
|
|
6
|
-
from sqlalchemy.dialects.postgresql import ENUM as PGEnum # اختیاری
|
|
7
|
-
except Exception:
|
|
8
|
-
PGEnum = type("PGEnum", (), {}) # fallback dummy
|
|
9
|
-
|
|
10
|
-
from nlbone.interfaces.api.exceptions import UnprocessableEntityException
|
|
11
|
-
from nlbone.interfaces.api.pagination import PaginateRequest
|
|
12
|
-
from .coercion import coerce_value_for_column, to_like_pattern, is_text_type, looks_like_wildcard
|
|
13
|
-
from .types import NULL_SENTINELS, InvalidEnum, parse_field_and_op
|
|
14
|
-
|
|
15
|
-
def apply_filters_to_query(pagination: PaginateRequest, entity, query: Query) -> Query:
|
|
16
|
-
if not getattr(pagination, "filters", None):
|
|
17
|
-
return query
|
|
18
|
-
|
|
19
|
-
for raw_field, value in pagination.filters.items():
|
|
20
|
-
if value is None or value in NULL_SENTINELS or value == [] or value == {}:
|
|
21
|
-
value = None
|
|
22
|
-
|
|
23
|
-
field, op_hint = parse_field_and_op(raw_field)
|
|
24
|
-
if not hasattr(entity, field):
|
|
25
|
-
continue
|
|
26
|
-
|
|
27
|
-
col = getattr(entity, field)
|
|
28
|
-
coltype = getattr(col, "type", None)
|
|
29
|
-
|
|
30
|
-
def _use_ilike(v) -> bool:
|
|
31
|
-
if op_hint == "ilike":
|
|
32
|
-
return True
|
|
33
|
-
return is_text_type(coltype) and isinstance(v, str) and looks_like_wildcard(v)
|
|
34
|
-
|
|
35
|
-
try:
|
|
36
|
-
if isinstance(value, (list, tuple, set)):
|
|
37
|
-
vals = [v for v in value if v not in (None, "", "null", "None")]
|
|
38
|
-
if not vals:
|
|
39
|
-
continue
|
|
40
|
-
if any(_use_ilike(v) for v in vals) and is_text_type(coltype):
|
|
41
|
-
patterns = [to_like_pattern(str(v)) for v in vals]
|
|
42
|
-
query = query.filter(or_(*[col.ilike(p) for p in patterns]))
|
|
43
|
-
else:
|
|
44
|
-
coerced = [coerce_value_for_column(coltype, v) for v in vals]
|
|
45
|
-
if not coerced:
|
|
46
|
-
continue
|
|
47
|
-
query = query.filter(col.in_(coerced))
|
|
48
|
-
else:
|
|
49
|
-
if _use_ilike(value) and is_text_type(coltype):
|
|
50
|
-
query = query.filter(col.ilike(to_like_pattern(str(value))))
|
|
51
|
-
else:
|
|
52
|
-
v = coerce_value_for_column(coltype, value)
|
|
53
|
-
query = query.filter(col.is_(None) if v is None else (col == v))
|
|
54
|
-
except InvalidEnum as e:
|
|
55
|
-
raise UnprocessableEntityException(str(e), loc=["query", "filters", raw_field]) from e
|
|
56
|
-
|
|
57
|
-
return query
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
from sqlalchemy import asc, desc
|
|
2
|
-
from sqlalchemy.orm import Query
|
|
3
|
-
|
|
4
|
-
def apply_order_to_query(pagination, entity, query: Query) -> Query:
|
|
5
|
-
if not pagination.sort:
|
|
6
|
-
return query
|
|
7
|
-
clauses = []
|
|
8
|
-
for s in pagination.sort:
|
|
9
|
-
field = s["field"]
|
|
10
|
-
order = s["order"]
|
|
11
|
-
if hasattr(entity, field):
|
|
12
|
-
col = getattr(entity, field)
|
|
13
|
-
clauses.append(asc(col) if order == "asc" else desc(col))
|
|
14
|
-
return query.order_by(*clauses) if clauses else query
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
NULL_SENTINELS = {"None", "null", ""}
|
|
2
|
-
|
|
3
|
-
class InvalidEnum(Exception):
|
|
4
|
-
pass
|
|
5
|
-
|
|
6
|
-
def parse_field_and_op(field: str) -> tuple[str, str]:
|
|
7
|
-
if "__" in field:
|
|
8
|
-
base, op = field.rsplit("__", 1)
|
|
9
|
-
if op.lower() == "ilike":
|
|
10
|
-
return base, "ilike"
|
|
11
|
-
return field, "eq"
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
from typing import Any, Iterable
|
|
2
|
-
from fastapi import HTTPException, status
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
def _error_entry(loc: Iterable[Any] | None, msg: str, type_: str) -> dict:
|
|
6
|
-
return {
|
|
7
|
-
"loc": list(loc) if loc else [],
|
|
8
|
-
"msg": msg,
|
|
9
|
-
"type": type_,
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
def _errors(loc: Iterable[Any] | None, msg: str, type_: str) -> list[dict]:
|
|
14
|
-
return [_error_entry(loc, msg, type_)]
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
class BadRequestException(HTTPException):
|
|
18
|
-
def __init__(self, msg: str):
|
|
19
|
-
super().__init__(
|
|
20
|
-
status_code=status.HTTP_400_BAD_REQUEST,
|
|
21
|
-
detail=msg,
|
|
22
|
-
)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
class UnauthorizedException(HTTPException):
|
|
26
|
-
def __init__(self, msg: str = "unauthorized"):
|
|
27
|
-
super().__init__(
|
|
28
|
-
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
29
|
-
detail=msg,
|
|
30
|
-
)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
class ForbiddenException(HTTPException):
|
|
34
|
-
def __init__(self, msg: str = "forbidden"):
|
|
35
|
-
super().__init__(
|
|
36
|
-
status_code=status.HTTP_403_FORBIDDEN,
|
|
37
|
-
detail=msg,
|
|
38
|
-
)
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
class NotFoundException(HTTPException):
|
|
42
|
-
def __init__(self, msg: str = "not found"):
|
|
43
|
-
super().__init__(
|
|
44
|
-
status_code=status.HTTP_404_NOT_FOUND,
|
|
45
|
-
detail=msg,
|
|
46
|
-
)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
class ConflictException(HTTPException):
|
|
50
|
-
def __init__(self, msg: str = "conflict"):
|
|
51
|
-
super().__init__(
|
|
52
|
-
status_code=status.HTTP_409_CONFLICT,
|
|
53
|
-
detail=msg,
|
|
54
|
-
)
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
class UnprocessableEntityException(HTTPException):
|
|
58
|
-
def __init__(self, msg: str, loc: Iterable[Any] | None = None, type_: str = "unprocessable_entity"):
|
|
59
|
-
super().__init__(
|
|
60
|
-
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
61
|
-
detail=_errors(loc, msg, type_),
|
|
62
|
-
)
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
class LogicalValidationException(UnprocessableEntityException):
|
|
66
|
-
def __init__(self, msg: str, loc: Iterable[Any] | None = None, type_: str = "logical_error"):
|
|
67
|
-
super().__init__(msg=msg, loc=loc, type_=type_)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|