nlbone 0.1.38__tar.gz → 0.3.0__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.1.38 → nlbone-0.3.0}/PKG-INFO +1 -1
- {nlbone-0.1.38 → nlbone-0.3.0}/pyproject.toml +1 -1
- {nlbone-0.1.38 → nlbone-0.3.0}/src/nlbone/adapters/db/__init__.py +2 -1
- nlbone-0.3.0/src/nlbone/adapters/db/sqlalchemy/__init__.py +1 -0
- {nlbone-0.1.38/src/nlbone/adapters/db → nlbone-0.3.0/src/nlbone/adapters/db/sqlalchemy}/query_builder.py +81 -4
- {nlbone-0.1.38 → nlbone-0.3.0}/src/nlbone/config/settings.py +2 -2
- nlbone-0.3.0/src/nlbone/interfaces/api/dependencies/auth.py +48 -0
- nlbone-0.3.0/src/nlbone/interfaces/api/exception_handlers.py +107 -0
- nlbone-0.3.0/src/nlbone/interfaces/api/exceptions.py +81 -0
- nlbone-0.3.0/src/nlbone/interfaces/api/pagination/__init__.py +20 -0
- {nlbone-0.1.38 → nlbone-0.3.0}/src/nlbone/interfaces/api/pagination/offset_base.py +1 -24
- nlbone-0.1.38/src/nlbone/adapters/db/sqlalchemy/__init__.py +0 -4
- nlbone-0.1.38/src/nlbone/adapters/db/sqlalchemy/query/__init__.py +0 -3
- nlbone-0.1.38/src/nlbone/adapters/db/sqlalchemy/query/builder.py +0 -21
- nlbone-0.1.38/src/nlbone/adapters/db/sqlalchemy/query/coercion.py +0 -64
- nlbone-0.1.38/src/nlbone/adapters/db/sqlalchemy/query/filters.py +0 -57
- nlbone-0.1.38/src/nlbone/adapters/db/sqlalchemy/query/ordering.py +0 -14
- nlbone-0.1.38/src/nlbone/adapters/db/sqlalchemy/query/types.py +0 -11
- nlbone-0.1.38/src/nlbone/interfaces/api/exceptions.py +0 -8
- nlbone-0.1.38/src/nlbone/interfaces/api/pagination/__init__.py +0 -1
- {nlbone-0.1.38 → nlbone-0.3.0}/.gitignore +0 -0
- {nlbone-0.1.38 → nlbone-0.3.0}/LICENSE +0 -0
- {nlbone-0.1.38 → nlbone-0.3.0}/README.md +0 -0
- {nlbone-0.1.38 → nlbone-0.3.0}/src/nlbone/__init__.py +0 -0
- {nlbone-0.1.38 → nlbone-0.3.0}/src/nlbone/adapters/__init__.py +0 -0
- {nlbone-0.1.38 → nlbone-0.3.0}/src/nlbone/adapters/auth/__init__.py +0 -0
- {nlbone-0.1.38 → nlbone-0.3.0}/src/nlbone/adapters/auth/keycloak.py +0 -0
- {nlbone-0.1.38 → nlbone-0.3.0}/src/nlbone/adapters/db/memory.py +0 -0
- {nlbone-0.1.38 → nlbone-0.3.0}/src/nlbone/adapters/db/postgres.py +0 -0
- {nlbone-0.1.38 → nlbone-0.3.0}/src/nlbone/adapters/db/sqlalchemy/base.py +0 -0
- {nlbone-0.1.38 → nlbone-0.3.0}/src/nlbone/adapters/db/sqlalchemy/engine.py +0 -0
- {nlbone-0.1.38 → nlbone-0.3.0}/src/nlbone/adapters/db/sqlalchemy/schema.py +0 -0
- {nlbone-0.1.38 → nlbone-0.3.0}/src/nlbone/adapters/http_clients/__init__.py +0 -0
- {nlbone-0.1.38 → nlbone-0.3.0}/src/nlbone/adapters/http_clients/email_gateway.py +0 -0
- {nlbone-0.1.38 → nlbone-0.3.0}/src/nlbone/adapters/http_clients/uploadchi.py +0 -0
- {nlbone-0.1.38 → nlbone-0.3.0}/src/nlbone/adapters/http_clients/uploadchi_async.py +0 -0
- {nlbone-0.1.38 → nlbone-0.3.0}/src/nlbone/adapters/messaging/__init__.py +0 -0
- {nlbone-0.1.38 → nlbone-0.3.0}/src/nlbone/adapters/messaging/redis.py +0 -0
- {nlbone-0.1.38 → nlbone-0.3.0}/src/nlbone/config/__init__.py +0 -0
- {nlbone-0.1.38 → nlbone-0.3.0}/src/nlbone/config/logging.py +0 -0
- {nlbone-0.1.38 → nlbone-0.3.0}/src/nlbone/container.py +0 -0
- {nlbone-0.1.38 → nlbone-0.3.0}/src/nlbone/core/__init__.py +0 -0
- {nlbone-0.1.38 → nlbone-0.3.0}/src/nlbone/core/application/__init__.py +0 -0
- {nlbone-0.1.38 → nlbone-0.3.0}/src/nlbone/core/application/services/__init__.py +0 -0
- {nlbone-0.1.38 → nlbone-0.3.0}/src/nlbone/core/application/services.py +0 -0
- {nlbone-0.1.38 → nlbone-0.3.0}/src/nlbone/core/application/use_cases/__init__.py +0 -0
- {nlbone-0.1.38 → nlbone-0.3.0}/src/nlbone/core/application/use_cases/register_user.py +0 -0
- {nlbone-0.1.38 → nlbone-0.3.0}/src/nlbone/core/domain/__init__.py +0 -0
- {nlbone-0.1.38 → nlbone-0.3.0}/src/nlbone/core/domain/events.py +0 -0
- {nlbone-0.1.38 → nlbone-0.3.0}/src/nlbone/core/domain/models.py +0 -0
- {nlbone-0.1.38 → nlbone-0.3.0}/src/nlbone/core/ports/__init__.py +0 -0
- {nlbone-0.1.38 → nlbone-0.3.0}/src/nlbone/core/ports/auth.py +0 -0
- {nlbone-0.1.38 → nlbone-0.3.0}/src/nlbone/core/ports/files.py +0 -0
- {nlbone-0.1.38 → nlbone-0.3.0}/src/nlbone/core/ports/messaging.py +0 -0
- {nlbone-0.1.38 → nlbone-0.3.0}/src/nlbone/core/ports/repo.py +0 -0
- {nlbone-0.1.38 → nlbone-0.3.0}/src/nlbone/interfaces/__init__.py +0 -0
- {nlbone-0.1.38 → nlbone-0.3.0}/src/nlbone/interfaces/api/__init__.py +0 -0
- {nlbone-0.1.38 → nlbone-0.3.0}/src/nlbone/interfaces/api/dependencies/__init__.py +0 -0
- {nlbone-0.1.38 → nlbone-0.3.0}/src/nlbone/interfaces/api/dependencies/db.py +0 -0
- {nlbone-0.1.38 → nlbone-0.3.0}/src/nlbone/interfaces/api/middleware/__init__.py +0 -0
- {nlbone-0.1.38 → nlbone-0.3.0}/src/nlbone/interfaces/api/middleware/access_log.py +0 -0
- {nlbone-0.1.38 → nlbone-0.3.0}/src/nlbone/interfaces/api/middleware/add_request_context.py +0 -0
- {nlbone-0.1.38 → nlbone-0.3.0}/src/nlbone/interfaces/api/middleware/authentication.py +0 -0
- {nlbone-0.1.38 → nlbone-0.3.0}/src/nlbone/interfaces/api/routers.py +0 -0
- {nlbone-0.1.38 → nlbone-0.3.0}/src/nlbone/interfaces/api/schemas.py +0 -0
- {nlbone-0.1.38 → nlbone-0.3.0}/src/nlbone/interfaces/cli/__init__.py +0 -0
- {nlbone-0.1.38 → nlbone-0.3.0}/src/nlbone/interfaces/cli/init_db.py +0 -0
- {nlbone-0.1.38 → nlbone-0.3.0}/src/nlbone/interfaces/cli/main.py +0 -0
- {nlbone-0.1.38 → nlbone-0.3.0}/src/nlbone/interfaces/jobs/__init__.py +0 -0
- {nlbone-0.1.38 → nlbone-0.3.0}/src/nlbone/interfaces/jobs/sync_tokens.py +0 -0
- {nlbone-0.1.38 → nlbone-0.3.0}/src/nlbone/types.py +0 -0
- {nlbone-0.1.38 → nlbone-0.3.0}/src/nlbone/utils/__init__.py +0 -0
- {nlbone-0.1.38 → nlbone-0.3.0}/src/nlbone/utils/context.py +0 -0
- {nlbone-0.1.38 → nlbone-0.3.0}/src/nlbone/utils/time.py +0 -0
|
@@ -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
|
)
|
|
@@ -6,7 +9,7 @@ from sqlalchemy.orm import Session, Query
|
|
|
6
9
|
from sqlalchemy.dialects.postgresql import ENUM as PGEnum
|
|
7
10
|
|
|
8
11
|
from nlbone.interfaces.api.exceptions import UnprocessableEntityException
|
|
9
|
-
from nlbone.interfaces.api.pagination import PaginateRequest
|
|
12
|
+
from nlbone.interfaces.api.pagination import PaginateRequest, PaginateResponse
|
|
10
13
|
|
|
11
14
|
NULL_SENTINELS = {"None", "null", ""}
|
|
12
15
|
|
|
@@ -15,7 +18,7 @@ class _InvalidEnum(Exception):
|
|
|
15
18
|
pass
|
|
16
19
|
|
|
17
20
|
|
|
18
|
-
def _apply_order(pagination, entity, query):
|
|
21
|
+
def _apply_order(pagination: PaginateRequest, entity, query):
|
|
19
22
|
if pagination.sort:
|
|
20
23
|
order_clauses = []
|
|
21
24
|
for sort in pagination.sort:
|
|
@@ -170,10 +173,84 @@ 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:
|
|
178
182
|
query = query.limit(pagination.limit).offset(pagination.offset)
|
|
179
183
|
return query
|
|
184
|
+
|
|
185
|
+
|
|
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
|
+
|
|
244
|
+
total_count = None
|
|
245
|
+
if with_count:
|
|
246
|
+
total_count = query.count()
|
|
247
|
+
query = query.limit(pagination.limit).offset(pagination.offset)
|
|
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()
|
|
@@ -15,8 +15,8 @@ def _guess_env_file() -> str | None:
|
|
|
15
15
|
if cwd_env.exists():
|
|
16
16
|
return str(cwd_env)
|
|
17
17
|
|
|
18
|
-
for i in range(
|
|
19
|
-
p = Path(
|
|
18
|
+
for i in range(0, 8):
|
|
19
|
+
p = Path.cwd().resolve().parents[i]
|
|
20
20
|
f = p / ".env"
|
|
21
21
|
if f.exists():
|
|
22
22
|
return str(f)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
|
|
3
|
+
from nlbone.adapters.auth import KeycloakAuthService
|
|
4
|
+
from nlbone.interfaces.api.exceptions import ForbiddenException, UnauthorizedException
|
|
5
|
+
from nlbone.utils.context import current_request
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def client_has_access(*, permissions=None):
|
|
9
|
+
def decorator(func):
|
|
10
|
+
@functools.wraps(func)
|
|
11
|
+
def wrapper(*args, **kwargs):
|
|
12
|
+
request = current_request()
|
|
13
|
+
if not KeycloakAuthService().client_has_access(request.state.token, permissions=permissions):
|
|
14
|
+
raise ForbiddenException(f"Forbidden {permissions}")
|
|
15
|
+
|
|
16
|
+
return func(*args, **kwargs)
|
|
17
|
+
|
|
18
|
+
return wrapper
|
|
19
|
+
return decorator
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def user_authenticated(func):
|
|
24
|
+
@functools.wraps(func)
|
|
25
|
+
async def wrapper(*args, **kwargs):
|
|
26
|
+
request = current_request()
|
|
27
|
+
if not request.state.user_id:
|
|
28
|
+
raise UnauthorizedException()
|
|
29
|
+
return await func(*args, **kwargs)
|
|
30
|
+
|
|
31
|
+
return wrapper
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def has_access(*, permissions=None):
|
|
35
|
+
def decorator(func):
|
|
36
|
+
@functools.wraps(func)
|
|
37
|
+
def wrapper(*args, **kwargs):
|
|
38
|
+
request = current_request()
|
|
39
|
+
if not request.state.user_id:
|
|
40
|
+
raise UnauthorizedException()
|
|
41
|
+
if not KeycloakAuthService().has_access(request.state.token, permissions=permissions):
|
|
42
|
+
raise ForbiddenException(f"Forbidden {permissions}")
|
|
43
|
+
|
|
44
|
+
return func(*args, **kwargs)
|
|
45
|
+
|
|
46
|
+
return wrapper
|
|
47
|
+
return decorator
|
|
48
|
+
|
|
@@ -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,6 +1,3 @@
|
|
|
1
|
-
# src/nlbone/interfaces/api/pagination.py
|
|
2
|
-
from __future__ import annotations
|
|
3
|
-
|
|
4
1
|
import json
|
|
5
2
|
from math import ceil
|
|
6
3
|
from typing import Any, Optional
|
|
@@ -21,7 +18,7 @@ class PaginateRequest:
|
|
|
21
18
|
limit: int = 10,
|
|
22
19
|
offset: int = 0,
|
|
23
20
|
sort: Optional[str] = None,
|
|
24
|
-
filters: Optional[str] = Query(None, description="e.g. title:abc
|
|
21
|
+
filters: Optional[str] = Query(None, description="e.g. title:abc"),
|
|
25
22
|
) -> None:
|
|
26
23
|
self.limit = max(0, limit)
|
|
27
24
|
self.offset = max(0, offset)
|
|
@@ -78,26 +75,6 @@ class PaginateRequest:
|
|
|
78
75
|
filters_dict[key] = value_cast
|
|
79
76
|
return filters_dict
|
|
80
77
|
|
|
81
|
-
# Helpers for SQLAlchemy usage:
|
|
82
|
-
def build_order_by(self, model) -> list[Any]:
|
|
83
|
-
"""
|
|
84
|
-
Translate parsed sort to SQLAlchemy order_by list.
|
|
85
|
-
Example: model.created_at.desc(), model.id.asc()
|
|
86
|
-
"""
|
|
87
|
-
items: list[Any] = []
|
|
88
|
-
for item in self.sort:
|
|
89
|
-
field = getattr(model, item["field"], None)
|
|
90
|
-
if field is None:
|
|
91
|
-
continue
|
|
92
|
-
items.append(desc(field) if item["order"] == "desc" else asc(field))
|
|
93
|
-
return items
|
|
94
|
-
|
|
95
|
-
def filter_kwargs(self) -> dict[str, Any]:
|
|
96
|
-
"""
|
|
97
|
-
Return simple equality filters (for model.filter_by(**kwargs)).
|
|
98
|
-
For advanced ops, extend this to parse operators (gt, lt, like, in, etc.).
|
|
99
|
-
"""
|
|
100
|
-
return {k: v for k, v in self.filters.items() if k != "$"}
|
|
101
78
|
|
|
102
79
|
|
|
103
80
|
class PaginateResponse:
|
|
@@ -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,8 +0,0 @@
|
|
|
1
|
-
from fastapi import HTTPException, status
|
|
2
|
-
|
|
3
|
-
class UnprocessableEntityException(HTTPException):
|
|
4
|
-
def __init__(self, detail: str, loc: list[str] | None = None):
|
|
5
|
-
super().__init__(
|
|
6
|
-
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
7
|
-
detail={"msg": detail, "loc": loc or []},
|
|
8
|
-
)
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
from .offset_base import PaginateResponse, PaginateRequest
|
|
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
|