nene2-python 1.0.0__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.
- nene2/__init__.py +1 -0
- nene2/auth/__init__.py +16 -0
- nene2/auth/api_key.py +39 -0
- nene2/auth/bearer_token.py +51 -0
- nene2/auth/exceptions.py +8 -0
- nene2/auth/interfaces.py +23 -0
- nene2/auth/local_verifier.py +17 -0
- nene2/config/__init__.py +5 -0
- nene2/config/settings.py +72 -0
- nene2/database/__init__.py +15 -0
- nene2/database/exceptions.py +5 -0
- nene2/database/health.py +24 -0
- nene2/database/interfaces.py +51 -0
- nene2/database/sqlalchemy_executor.py +128 -0
- nene2/http/__init__.py +14 -0
- nene2/http/health.py +20 -0
- nene2/http/pagination.py +93 -0
- nene2/http/problem_details.py +37 -0
- nene2/log/__init__.py +5 -0
- nene2/log/setup.py +49 -0
- nene2/mcp/__init__.py +11 -0
- nene2/mcp/http_client.py +97 -0
- nene2/mcp/server.py +25 -0
- nene2/middleware/__init__.py +20 -0
- nene2/middleware/domain_exception.py +18 -0
- nene2/middleware/error_handler.py +112 -0
- nene2/middleware/request_id.py +45 -0
- nene2/middleware/request_logging.py +34 -0
- nene2/middleware/request_size_limit.py +52 -0
- nene2/middleware/security_headers.py +37 -0
- nene2/middleware/throttle.py +72 -0
- nene2/py.typed +0 -0
- nene2/use_case/__init__.py +5 -0
- nene2/use_case/protocols.py +24 -0
- nene2/validation/__init__.py +5 -0
- nene2/validation/exceptions.py +34 -0
- nene2_python-1.0.0.dist-info/METADATA +211 -0
- nene2_python-1.0.0.dist-info/RECORD +40 -0
- nene2_python-1.0.0.dist-info/WHEEL +4 -0
- nene2_python-1.0.0.dist-info/licenses/LICENSE +21 -0
nene2/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""NENE2 Python — minimal API framework."""
|
nene2/auth/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""NENE2 authentication layer."""
|
|
2
|
+
|
|
3
|
+
from .api_key import ApiKeyAuthMiddleware
|
|
4
|
+
from .bearer_token import BearerTokenMiddleware
|
|
5
|
+
from .exceptions import TokenVerificationException
|
|
6
|
+
from .interfaces import TokenIssuerProtocol, TokenVerifierProtocol
|
|
7
|
+
from .local_verifier import LocalTokenVerifier
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"ApiKeyAuthMiddleware",
|
|
11
|
+
"BearerTokenMiddleware",
|
|
12
|
+
"LocalTokenVerifier",
|
|
13
|
+
"TokenIssuerProtocol",
|
|
14
|
+
"TokenVerificationException",
|
|
15
|
+
"TokenVerifierProtocol",
|
|
16
|
+
]
|
nene2/auth/api_key.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""API Key authentication middleware.
|
|
2
|
+
|
|
3
|
+
Validates X-Api-Key header using a TokenVerifierProtocol.
|
|
4
|
+
Returns 401 Problem Details when key is absent or invalid.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
|
8
|
+
from starlette.requests import Request
|
|
9
|
+
from starlette.responses import Response
|
|
10
|
+
|
|
11
|
+
from nene2.http.problem_details import problem_details_response
|
|
12
|
+
|
|
13
|
+
from .exceptions import TokenVerificationException
|
|
14
|
+
from .interfaces import TokenVerifierProtocol
|
|
15
|
+
|
|
16
|
+
_API_KEY_HEADER = "X-Api-Key"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ApiKeyAuthMiddleware(BaseHTTPMiddleware):
|
|
20
|
+
"""Require a valid X-Api-Key header on every request."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, app: object, *, verifier: TokenVerifierProtocol) -> None:
|
|
23
|
+
super().__init__(app) # type: ignore[arg-type]
|
|
24
|
+
self._verifier = verifier
|
|
25
|
+
|
|
26
|
+
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
|
27
|
+
api_key = request.headers.get(_API_KEY_HEADER, "")
|
|
28
|
+
try:
|
|
29
|
+
verified = bool(api_key) and self._verifier.verify(api_key)
|
|
30
|
+
except TokenVerificationException:
|
|
31
|
+
verified = False
|
|
32
|
+
if not verified:
|
|
33
|
+
return problem_details_response(
|
|
34
|
+
"unauthorized",
|
|
35
|
+
"Unauthorized",
|
|
36
|
+
401,
|
|
37
|
+
"A valid X-Api-Key header is required.",
|
|
38
|
+
)
|
|
39
|
+
return await call_next(request)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Bearer token authentication middleware.
|
|
2
|
+
|
|
3
|
+
Validates Authorization: Bearer <token> header.
|
|
4
|
+
Returns 401 Problem Details when token is absent or invalid.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
|
8
|
+
from starlette.requests import Request
|
|
9
|
+
from starlette.responses import Response
|
|
10
|
+
|
|
11
|
+
from nene2.http.problem_details import problem_details_response
|
|
12
|
+
|
|
13
|
+
from .exceptions import TokenVerificationException
|
|
14
|
+
from .interfaces import TokenVerifierProtocol
|
|
15
|
+
|
|
16
|
+
_WWW_AUTH = 'Bearer realm="api"'
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BearerTokenMiddleware(BaseHTTPMiddleware):
|
|
20
|
+
"""Require a valid Bearer token on every request."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, app: object, *, verifier: TokenVerifierProtocol) -> None:
|
|
23
|
+
super().__init__(app) # type: ignore[arg-type]
|
|
24
|
+
self._verifier = verifier
|
|
25
|
+
|
|
26
|
+
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
|
27
|
+
auth = request.headers.get("Authorization", "")
|
|
28
|
+
if not auth.startswith("Bearer "):
|
|
29
|
+
response = problem_details_response(
|
|
30
|
+
"unauthorized",
|
|
31
|
+
"Unauthorized",
|
|
32
|
+
401,
|
|
33
|
+
"A valid Bearer token is required.",
|
|
34
|
+
)
|
|
35
|
+
response.headers["WWW-Authenticate"] = _WWW_AUTH
|
|
36
|
+
return response
|
|
37
|
+
token = auth[len("Bearer ") :]
|
|
38
|
+
try:
|
|
39
|
+
verified = self._verifier.verify(token)
|
|
40
|
+
except TokenVerificationException:
|
|
41
|
+
verified = False
|
|
42
|
+
if not verified:
|
|
43
|
+
response = problem_details_response(
|
|
44
|
+
"unauthorized",
|
|
45
|
+
"Unauthorized",
|
|
46
|
+
401,
|
|
47
|
+
"The provided token is invalid or expired.",
|
|
48
|
+
)
|
|
49
|
+
response.headers["WWW-Authenticate"] = _WWW_AUTH
|
|
50
|
+
return response
|
|
51
|
+
return await call_next(request)
|
nene2/auth/exceptions.py
ADDED
nene2/auth/interfaces.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Authentication interfaces."""
|
|
2
|
+
|
|
3
|
+
from typing import Protocol, runtime_checkable
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@runtime_checkable
|
|
7
|
+
class TokenVerifierProtocol(Protocol):
|
|
8
|
+
"""Verify an authentication token.
|
|
9
|
+
|
|
10
|
+
Implementations may raise TokenVerificationException instead of returning False.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def verify(self, token: str) -> bool: ...
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@runtime_checkable
|
|
17
|
+
class TokenIssuerProtocol(Protocol):
|
|
18
|
+
"""Issue a signed bearer token from the given claims.
|
|
19
|
+
|
|
20
|
+
Production implementations wrap a JWT library (e.g. PyJWT).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def issue(self, claims: dict[str, object]) -> str: ...
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Local token verifier — compares against a fixed set of allowed tokens.
|
|
2
|
+
|
|
3
|
+
For development and testing only. In production, implement TokenVerifierProtocol
|
|
4
|
+
against your actual auth backend (database, external IdP, JWT, etc.).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import secrets
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LocalTokenVerifier:
|
|
11
|
+
"""Verify tokens against a fixed allowlist using constant-time comparison."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, allowed_tokens: list[str]) -> None:
|
|
14
|
+
self._allowed = allowed_tokens
|
|
15
|
+
|
|
16
|
+
def verify(self, token: str) -> bool:
|
|
17
|
+
return any(secrets.compare_digest(token, allowed) for allowed in self._allowed)
|
nene2/config/__init__.py
ADDED
nene2/config/settings.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Typed application settings loaded from environment variables."""
|
|
2
|
+
|
|
3
|
+
from pydantic import SecretStr, field_validator
|
|
4
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AppSettings(BaseSettings):
|
|
8
|
+
"""Application configuration loaded from environment variables."""
|
|
9
|
+
|
|
10
|
+
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
|
11
|
+
|
|
12
|
+
app_env: str = "local"
|
|
13
|
+
app_debug: bool = False
|
|
14
|
+
app_name: str = "nene2-python"
|
|
15
|
+
security_headers_enabled: bool = True
|
|
16
|
+
max_body_size: int = 1_048_576 # 1 MiB
|
|
17
|
+
throttle_enabled: bool = True
|
|
18
|
+
throttle_limit: int = 60
|
|
19
|
+
throttle_window: int = 60 # seconds
|
|
20
|
+
|
|
21
|
+
cors_enabled: bool = False
|
|
22
|
+
cors_origins: list[str] = []
|
|
23
|
+
cors_allow_credentials: bool = False
|
|
24
|
+
cors_allow_methods: list[str] = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
|
|
25
|
+
cors_allow_headers: list[str] = [
|
|
26
|
+
"Content-Type",
|
|
27
|
+
"Authorization",
|
|
28
|
+
"X-Api-Key",
|
|
29
|
+
"X-Request-Id",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
bearer_token_enabled: bool = False
|
|
33
|
+
bearer_tokens: list[str] = []
|
|
34
|
+
|
|
35
|
+
api_key_enabled: bool = False
|
|
36
|
+
api_keys: list[str] = []
|
|
37
|
+
|
|
38
|
+
db_adapter: str = "sqlite"
|
|
39
|
+
db_name: str = ":memory:"
|
|
40
|
+
db_host: str = "localhost"
|
|
41
|
+
db_port: int = 3306
|
|
42
|
+
db_user: str = ""
|
|
43
|
+
db_password: SecretStr = SecretStr("")
|
|
44
|
+
|
|
45
|
+
@field_validator("app_env")
|
|
46
|
+
@classmethod
|
|
47
|
+
def validate_app_env(cls, v: str) -> str:
|
|
48
|
+
allowed = {"local", "test", "production"}
|
|
49
|
+
if v not in allowed:
|
|
50
|
+
raise ValueError(f"app_env must be one of {allowed}")
|
|
51
|
+
return v
|
|
52
|
+
|
|
53
|
+
@field_validator("db_adapter")
|
|
54
|
+
@classmethod
|
|
55
|
+
def validate_adapter(cls, v: str) -> str:
|
|
56
|
+
allowed = {"sqlite", "mysql", "pgsql"}
|
|
57
|
+
if v not in allowed:
|
|
58
|
+
raise ValueError(f"db_adapter must be one of {allowed}")
|
|
59
|
+
return v
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def db_url(self) -> str:
|
|
63
|
+
"""Build a SQLAlchemy connection URL from adapter + credentials."""
|
|
64
|
+
if self.db_adapter == "sqlite":
|
|
65
|
+
return f"sqlite:///{self.db_name}"
|
|
66
|
+
password = self.db_password.get_secret_value()
|
|
67
|
+
port = self.db_port
|
|
68
|
+
if self.db_adapter == "mysql":
|
|
69
|
+
return f"mysql+pymysql://{self.db_user}:{password}@{self.db_host}:{port}/{self.db_name}"
|
|
70
|
+
return (
|
|
71
|
+
f"postgresql+psycopg2://{self.db_user}:{password}@{self.db_host}:{port}/{self.db_name}"
|
|
72
|
+
)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""NENE2 database abstraction layer."""
|
|
2
|
+
|
|
3
|
+
from .exceptions import DatabaseConnectionException
|
|
4
|
+
from .health import DatabaseHealthCheck
|
|
5
|
+
from .interfaces import DatabaseQueryExecutorInterface, DatabaseTransactionManagerInterface
|
|
6
|
+
from .sqlalchemy_executor import SqlAlchemyQueryExecutor, SqlAlchemyTransactionManager
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"DatabaseConnectionException",
|
|
10
|
+
"DatabaseHealthCheck",
|
|
11
|
+
"DatabaseQueryExecutorInterface",
|
|
12
|
+
"DatabaseTransactionManagerInterface",
|
|
13
|
+
"SqlAlchemyQueryExecutor",
|
|
14
|
+
"SqlAlchemyTransactionManager",
|
|
15
|
+
]
|
nene2/database/health.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Database health check — verifies DB connectivity for /health endpoint."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from nene2.http import HealthStatus
|
|
6
|
+
|
|
7
|
+
from .interfaces import DatabaseQueryExecutorInterface
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DatabaseHealthCheck:
|
|
13
|
+
"""Check database connectivity by executing a lightweight query."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, executor: DatabaseQueryExecutorInterface) -> None:
|
|
16
|
+
self._executor = executor
|
|
17
|
+
|
|
18
|
+
def check(self) -> HealthStatus:
|
|
19
|
+
try:
|
|
20
|
+
self._executor.fetch_one("SELECT 1 AS ok")
|
|
21
|
+
return HealthStatus(status="ok", checks={"database": "ok"})
|
|
22
|
+
except Exception as exc:
|
|
23
|
+
logger.warning("database health check failed: %s", exc)
|
|
24
|
+
return HealthStatus(status="error", checks={"database": "error"})
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Database abstraction interfaces.
|
|
2
|
+
|
|
3
|
+
Equivalent to PHP Nene2\\Database\\DatabaseQueryExecutorInterface
|
|
4
|
+
and DatabaseTransactionManagerInterface.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DatabaseQueryExecutorInterface(ABC):
|
|
13
|
+
"""Execute parameterised SQL queries against a database."""
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
def fetch_all(self, sql: str, params: dict[str, Any] | None = None) -> list[dict[str, Any]]: ...
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def fetch_one(
|
|
20
|
+
self, sql: str, params: dict[str, Any] | None = None
|
|
21
|
+
) -> dict[str, Any] | None: ...
|
|
22
|
+
|
|
23
|
+
@abstractmethod
|
|
24
|
+
def write(self, sql: str, params: dict[str, Any] | None = None) -> int:
|
|
25
|
+
"""Execute INSERT / UPDATE / DELETE.
|
|
26
|
+
|
|
27
|
+
Returns lastrowid for INSERT, affected rowcount for UPDATE/DELETE.
|
|
28
|
+
"""
|
|
29
|
+
...
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class DatabaseTransactionManagerInterface(ABC):
|
|
33
|
+
"""Manage database transactions.
|
|
34
|
+
|
|
35
|
+
High-level API: use transactional() — it commits on success and rolls back on exception.
|
|
36
|
+
Low-level API: begin() / commit() / rollback() for manual control.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
@abstractmethod
|
|
40
|
+
def transactional[T](self, callback: Callable[[DatabaseQueryExecutorInterface], T]) -> T:
|
|
41
|
+
"""Run callback inside a transaction; commit on success, rollback on exception."""
|
|
42
|
+
...
|
|
43
|
+
|
|
44
|
+
@abstractmethod
|
|
45
|
+
def begin(self) -> None: ...
|
|
46
|
+
|
|
47
|
+
@abstractmethod
|
|
48
|
+
def commit(self) -> None: ...
|
|
49
|
+
|
|
50
|
+
@abstractmethod
|
|
51
|
+
def rollback(self) -> None: ...
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""SQLAlchemy Core implementation of database interfaces.
|
|
2
|
+
|
|
3
|
+
Supports SQLite, MySQL, and PostgreSQL via SQLAlchemy's engine URL.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from sqlalchemy import Connection, Engine, text
|
|
10
|
+
from sqlalchemy.exc import OperationalError
|
|
11
|
+
|
|
12
|
+
from .exceptions import DatabaseConnectionException
|
|
13
|
+
from .interfaces import DatabaseQueryExecutorInterface, DatabaseTransactionManagerInterface
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SqlAlchemyQueryExecutor(DatabaseQueryExecutorInterface):
|
|
17
|
+
"""Execute queries using SQLAlchemy Core (connection-per-call, no ORM)."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, engine: Engine) -> None:
|
|
20
|
+
self._engine = engine
|
|
21
|
+
|
|
22
|
+
def fetch_all(self, sql: str, params: dict[str, Any] | None = None) -> list[dict[str, Any]]:
|
|
23
|
+
try:
|
|
24
|
+
with self._engine.connect() as conn:
|
|
25
|
+
result = conn.execute(text(sql), params or {})
|
|
26
|
+
return [dict(row._mapping) for row in result]
|
|
27
|
+
except OperationalError as exc:
|
|
28
|
+
raise DatabaseConnectionException(str(exc)) from exc
|
|
29
|
+
|
|
30
|
+
def fetch_one(self, sql: str, params: dict[str, Any] | None = None) -> dict[str, Any] | None:
|
|
31
|
+
try:
|
|
32
|
+
with self._engine.connect() as conn:
|
|
33
|
+
result = conn.execute(text(sql), params or {})
|
|
34
|
+
row = result.fetchone()
|
|
35
|
+
return dict(row._mapping) if row else None
|
|
36
|
+
except OperationalError as exc:
|
|
37
|
+
raise DatabaseConnectionException(str(exc)) from exc
|
|
38
|
+
|
|
39
|
+
def write(self, sql: str, params: dict[str, Any] | None = None) -> int:
|
|
40
|
+
"""Execute INSERT / UPDATE / DELETE and return a meaningful int.
|
|
41
|
+
|
|
42
|
+
Return value semantics:
|
|
43
|
+
- INSERT with AUTOINCREMENT/SERIAL column → ``lastrowid`` (the new row's PK, always > 0)
|
|
44
|
+
- INSERT without auto-PK, or multi-row INSERT → falls back to ``rowcount``
|
|
45
|
+
- UPDATE / DELETE → ``rowcount`` (number of rows affected; 0 means nothing matched)
|
|
46
|
+
|
|
47
|
+
Use the return value to detect missing rows::
|
|
48
|
+
|
|
49
|
+
affected = executor.write("UPDATE ... WHERE id = :id", {"id": pk})
|
|
50
|
+
if affected == 0:
|
|
51
|
+
raise NotFoundException(pk)
|
|
52
|
+
"""
|
|
53
|
+
try:
|
|
54
|
+
with self._engine.begin() as conn:
|
|
55
|
+
result = conn.execute(text(sql), params or {})
|
|
56
|
+
return result.lastrowid or result.rowcount
|
|
57
|
+
except OperationalError as exc:
|
|
58
|
+
raise DatabaseConnectionException(str(exc)) from exc
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class _BoundQueryExecutor(DatabaseQueryExecutorInterface):
|
|
62
|
+
"""Query executor bound to an existing connection (within a transaction)."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, conn: Connection) -> None:
|
|
65
|
+
self._conn = conn
|
|
66
|
+
|
|
67
|
+
def fetch_all(self, sql: str, params: dict[str, Any] | None = None) -> list[dict[str, Any]]:
|
|
68
|
+
try:
|
|
69
|
+
result = self._conn.execute(text(sql), params or {})
|
|
70
|
+
return [dict(row._mapping) for row in result]
|
|
71
|
+
except OperationalError as exc:
|
|
72
|
+
raise DatabaseConnectionException(str(exc)) from exc
|
|
73
|
+
|
|
74
|
+
def fetch_one(self, sql: str, params: dict[str, Any] | None = None) -> dict[str, Any] | None:
|
|
75
|
+
try:
|
|
76
|
+
result = self._conn.execute(text(sql), params or {})
|
|
77
|
+
row = result.fetchone()
|
|
78
|
+
return dict(row._mapping) if row else None
|
|
79
|
+
except OperationalError as exc:
|
|
80
|
+
raise DatabaseConnectionException(str(exc)) from exc
|
|
81
|
+
|
|
82
|
+
def write(self, sql: str, params: dict[str, Any] | None = None) -> int:
|
|
83
|
+
try:
|
|
84
|
+
result = self._conn.execute(text(sql), params or {})
|
|
85
|
+
except OperationalError as exc:
|
|
86
|
+
raise DatabaseConnectionException(str(exc)) from exc
|
|
87
|
+
return result.lastrowid or result.rowcount
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class SqlAlchemyTransactionManager(DatabaseTransactionManagerInterface):
|
|
91
|
+
"""Manage database transactions using SQLAlchemy.
|
|
92
|
+
|
|
93
|
+
Use transactional() for the recommended callback-based API.
|
|
94
|
+
Use begin() / commit() / rollback() for explicit transaction control.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
def __init__(self, engine: Engine) -> None:
|
|
98
|
+
self._engine = engine
|
|
99
|
+
self._conn: Connection | None = None
|
|
100
|
+
# reason: RootTransaction is an internal SQLAlchemy type not exported in the public API
|
|
101
|
+
self._tx: Any = None
|
|
102
|
+
|
|
103
|
+
def transactional[T](self, callback: Callable[[DatabaseQueryExecutorInterface], T]) -> T:
|
|
104
|
+
try:
|
|
105
|
+
with self._engine.begin() as conn:
|
|
106
|
+
return callback(_BoundQueryExecutor(conn))
|
|
107
|
+
except OperationalError as exc:
|
|
108
|
+
raise DatabaseConnectionException(str(exc)) from exc
|
|
109
|
+
|
|
110
|
+
def begin(self) -> None:
|
|
111
|
+
self._conn = self._engine.connect()
|
|
112
|
+
self._tx = self._conn.begin()
|
|
113
|
+
|
|
114
|
+
def commit(self) -> None:
|
|
115
|
+
if self._tx is not None:
|
|
116
|
+
self._tx.commit()
|
|
117
|
+
self._tx = None
|
|
118
|
+
if self._conn is not None:
|
|
119
|
+
self._conn.close()
|
|
120
|
+
self._conn = None
|
|
121
|
+
|
|
122
|
+
def rollback(self) -> None:
|
|
123
|
+
if self._tx is not None:
|
|
124
|
+
self._tx.rollback()
|
|
125
|
+
self._tx = None
|
|
126
|
+
if self._conn is not None:
|
|
127
|
+
self._conn.close()
|
|
128
|
+
self._conn = None
|
nene2/http/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""HTTP helpers — JSON responses, pagination, problem details, health."""
|
|
2
|
+
|
|
3
|
+
from .health import HealthCheckProtocol, HealthStatus
|
|
4
|
+
from .pagination import PaginationQuery, PaginationQueryParser, PaginationResponse
|
|
5
|
+
from .problem_details import problem_details_response
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"HealthCheckProtocol",
|
|
9
|
+
"HealthStatus",
|
|
10
|
+
"PaginationQuery",
|
|
11
|
+
"PaginationQueryParser",
|
|
12
|
+
"PaginationResponse",
|
|
13
|
+
"problem_details_response",
|
|
14
|
+
]
|
nene2/http/health.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""HealthCheckProtocol and HealthStatus — framework health check contract."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True, slots=True)
|
|
8
|
+
class HealthStatus:
|
|
9
|
+
status: str
|
|
10
|
+
checks: dict[str, str] = field(default_factory=dict)
|
|
11
|
+
|
|
12
|
+
@property
|
|
13
|
+
def is_healthy(self) -> bool:
|
|
14
|
+
return self.status == "ok"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class HealthCheckProtocol(Protocol):
|
|
18
|
+
"""Contract for application health checks."""
|
|
19
|
+
|
|
20
|
+
def check(self) -> HealthStatus: ...
|
nene2/http/pagination.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Pagination helpers.
|
|
2
|
+
|
|
3
|
+
Equivalent to PHP Nene2\\Http\\PaginationQueryParser, PaginationQuery, PaginationResponse.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from fastapi import Request
|
|
10
|
+
|
|
11
|
+
from nene2.validation.exceptions import ValidationError, ValidationException
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class PaginationQuery:
|
|
16
|
+
"""Parsed and validated ?limit= / ?offset= parameters."""
|
|
17
|
+
|
|
18
|
+
limit: int
|
|
19
|
+
offset: int
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PaginationQueryParser:
|
|
23
|
+
"""Parses and validates pagination query parameters from a FastAPI Request."""
|
|
24
|
+
|
|
25
|
+
@staticmethod
|
|
26
|
+
def parse(
|
|
27
|
+
request: Request,
|
|
28
|
+
*,
|
|
29
|
+
default_limit: int = 20,
|
|
30
|
+
max_limit: int = 100,
|
|
31
|
+
) -> PaginationQuery:
|
|
32
|
+
"""Parse ?limit= and ?offset= from the request.
|
|
33
|
+
|
|
34
|
+
Raises ValidationException (→ 422) when values are out of range.
|
|
35
|
+
"""
|
|
36
|
+
params = request.query_params
|
|
37
|
+
|
|
38
|
+
errors: list[ValidationError] = []
|
|
39
|
+
try:
|
|
40
|
+
limit = int(params.get("limit", default_limit))
|
|
41
|
+
except ValueError:
|
|
42
|
+
errors.append(ValidationError("limit", "limit must be an integer.", "invalid"))
|
|
43
|
+
limit = default_limit
|
|
44
|
+
try:
|
|
45
|
+
offset = int(params.get("offset", 0))
|
|
46
|
+
except ValueError:
|
|
47
|
+
errors.append(ValidationError("offset", "offset must be an integer.", "invalid"))
|
|
48
|
+
offset = 0
|
|
49
|
+
|
|
50
|
+
if limit < 1 or limit > max_limit:
|
|
51
|
+
errors.append(
|
|
52
|
+
ValidationError(
|
|
53
|
+
field="limit",
|
|
54
|
+
message=f"limit must be between 1 and {max_limit}.",
|
|
55
|
+
code="out_of_range",
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
if offset < 0:
|
|
59
|
+
errors.append(
|
|
60
|
+
ValidationError(
|
|
61
|
+
field="offset",
|
|
62
|
+
message="offset must be 0 or greater.",
|
|
63
|
+
code="out_of_range",
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
if errors:
|
|
67
|
+
raise ValidationException(errors)
|
|
68
|
+
|
|
69
|
+
return PaginationQuery(limit=limit, offset=offset)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True, slots=True)
|
|
73
|
+
class PaginationResponse:
|
|
74
|
+
"""Standard list-endpoint response envelope.
|
|
75
|
+
|
|
76
|
+
Equivalent to PHP Nene2\\Http\\PaginationResponse.
|
|
77
|
+
The ``total`` key is omitted from the output when not provided.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
items: list[Any]
|
|
81
|
+
limit: int
|
|
82
|
+
offset: int
|
|
83
|
+
total: int | None = field(default=None)
|
|
84
|
+
|
|
85
|
+
def to_dict(self) -> dict[str, Any]:
|
|
86
|
+
data: dict[str, Any] = {
|
|
87
|
+
"items": self.items,
|
|
88
|
+
"limit": self.limit,
|
|
89
|
+
"offset": self.offset,
|
|
90
|
+
}
|
|
91
|
+
if self.total is not None:
|
|
92
|
+
data["total"] = self.total
|
|
93
|
+
return data
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""RFC 9457 Problem Details response factory.
|
|
2
|
+
|
|
3
|
+
Equivalent to PHP Nene2\\Error\\ProblemDetailsResponseFactory.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from fastapi.responses import JSONResponse
|
|
9
|
+
|
|
10
|
+
PROBLEM_DETAILS_BASE_URL = "https://nene2.dev/problems/"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def problem_details_response(
|
|
14
|
+
problem_type: str,
|
|
15
|
+
title: str,
|
|
16
|
+
status: int,
|
|
17
|
+
detail: str | None = None,
|
|
18
|
+
extra: dict[str, Any] | None = None,
|
|
19
|
+
*,
|
|
20
|
+
base_url: str = PROBLEM_DETAILS_BASE_URL,
|
|
21
|
+
) -> JSONResponse:
|
|
22
|
+
"""Build an RFC 9457 Problem Details JSON response."""
|
|
23
|
+
body: dict[str, Any] = {
|
|
24
|
+
"type": base_url + problem_type,
|
|
25
|
+
"title": title,
|
|
26
|
+
"status": status,
|
|
27
|
+
}
|
|
28
|
+
if detail:
|
|
29
|
+
body["detail"] = detail
|
|
30
|
+
if extra:
|
|
31
|
+
body.update(extra)
|
|
32
|
+
|
|
33
|
+
return JSONResponse(
|
|
34
|
+
content=body,
|
|
35
|
+
status_code=status,
|
|
36
|
+
media_type="application/problem+json",
|
|
37
|
+
)
|