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/log/setup.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Configure structlog for the application.
|
|
2
|
+
|
|
3
|
+
Call setup_logging() once at startup (in create_app).
|
|
4
|
+
- production / test: JSON renderer
|
|
5
|
+
- local (development): ConsoleRenderer with colors
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
import structlog
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def setup_logging(app_env: str = "local") -> None:
|
|
15
|
+
shared_processors: list[structlog.types.Processor] = [
|
|
16
|
+
structlog.stdlib.add_log_level,
|
|
17
|
+
structlog.stdlib.add_logger_name,
|
|
18
|
+
structlog.processors.TimeStamper(fmt="iso"),
|
|
19
|
+
structlog.contextvars.merge_contextvars,
|
|
20
|
+
structlog.processors.StackInfoRenderer(),
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
if app_env == "local":
|
|
24
|
+
renderer: structlog.types.Processor = structlog.dev.ConsoleRenderer()
|
|
25
|
+
else:
|
|
26
|
+
renderer = structlog.processors.JSONRenderer()
|
|
27
|
+
|
|
28
|
+
structlog.configure(
|
|
29
|
+
processors=[
|
|
30
|
+
*shared_processors,
|
|
31
|
+
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
|
|
32
|
+
],
|
|
33
|
+
logger_factory=structlog.stdlib.LoggerFactory(),
|
|
34
|
+
wrapper_class=structlog.stdlib.BoundLogger,
|
|
35
|
+
cache_logger_on_first_use=True,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
formatter = structlog.stdlib.ProcessorFormatter(
|
|
39
|
+
processor=renderer,
|
|
40
|
+
foreign_pre_chain=shared_processors,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
handler = logging.StreamHandler(sys.stdout)
|
|
44
|
+
handler.setFormatter(formatter)
|
|
45
|
+
|
|
46
|
+
root = logging.getLogger()
|
|
47
|
+
root.handlers.clear()
|
|
48
|
+
root.addHandler(handler)
|
|
49
|
+
root.setLevel(logging.INFO)
|
nene2/mcp/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""NENE2 MCP integration — expose UseCases as MCP tools."""
|
|
2
|
+
|
|
3
|
+
from .http_client import HttpxMcpClient, McpHttpClientProtocol, McpHttpResponse
|
|
4
|
+
from .server import LocalMcpServer
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"HttpxMcpClient",
|
|
8
|
+
"LocalMcpServer",
|
|
9
|
+
"McpHttpClientProtocol",
|
|
10
|
+
"McpHttpResponse",
|
|
11
|
+
]
|
nene2/mcp/http_client.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""MCP HTTP client — equivalent to PHP LocalMcpHttpClientInterface.
|
|
2
|
+
|
|
3
|
+
Provides a lightweight HTTP transport for calling a nene2 API from MCP tool handlers.
|
|
4
|
+
The default implementation uses httpx; inject a custom McpHttpClientProtocol for tests.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Protocol, runtime_checkable
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
from httpx import BaseTransport
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class McpHttpResponse:
|
|
16
|
+
"""HTTP response value object returned by McpHttpClientProtocol."""
|
|
17
|
+
|
|
18
|
+
status_code: int
|
|
19
|
+
headers: dict[str, str]
|
|
20
|
+
body: str
|
|
21
|
+
|
|
22
|
+
def is_successful(self) -> bool:
|
|
23
|
+
return 200 <= self.status_code < 300
|
|
24
|
+
|
|
25
|
+
def request_id(self) -> str | None:
|
|
26
|
+
return self.headers.get("x-request-id")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@runtime_checkable
|
|
30
|
+
class McpHttpClientProtocol(Protocol):
|
|
31
|
+
"""Structural contract for MCP HTTP clients."""
|
|
32
|
+
|
|
33
|
+
def get(self, base_url: str, path: str) -> McpHttpResponse: ...
|
|
34
|
+
|
|
35
|
+
def post(self, base_url: str, path: str, body: dict[str, object]) -> McpHttpResponse: ...
|
|
36
|
+
|
|
37
|
+
def put(self, base_url: str, path: str, body: dict[str, object]) -> McpHttpResponse: ...
|
|
38
|
+
|
|
39
|
+
def delete(self, base_url: str, path: str) -> McpHttpResponse: ...
|
|
40
|
+
|
|
41
|
+
def has_authentication(self) -> bool: ...
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class HttpxMcpClient:
|
|
45
|
+
"""httpx-backed MCP HTTP client with optional Bearer token authentication.
|
|
46
|
+
|
|
47
|
+
Pass a custom transport (e.g. httpx.MockTransport or httpx.WSGITransport)
|
|
48
|
+
for testing without making real network calls.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
bearer_token: str | None = None,
|
|
54
|
+
*,
|
|
55
|
+
transport: BaseTransport | None = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
self._bearer_token = bearer_token
|
|
58
|
+
self._transport = transport
|
|
59
|
+
|
|
60
|
+
def get(self, base_url: str, path: str) -> McpHttpResponse:
|
|
61
|
+
return self._request("GET", base_url, path, None)
|
|
62
|
+
|
|
63
|
+
def post(self, base_url: str, path: str, body: dict[str, object]) -> McpHttpResponse:
|
|
64
|
+
return self._request("POST", base_url, path, body)
|
|
65
|
+
|
|
66
|
+
def put(self, base_url: str, path: str, body: dict[str, object]) -> McpHttpResponse:
|
|
67
|
+
return self._request("PUT", base_url, path, body)
|
|
68
|
+
|
|
69
|
+
def delete(self, base_url: str, path: str) -> McpHttpResponse:
|
|
70
|
+
return self._request("DELETE", base_url, path, None)
|
|
71
|
+
|
|
72
|
+
def has_authentication(self) -> bool:
|
|
73
|
+
return self._bearer_token is not None
|
|
74
|
+
|
|
75
|
+
def _request(
|
|
76
|
+
self,
|
|
77
|
+
method: str,
|
|
78
|
+
base_url: str,
|
|
79
|
+
path: str,
|
|
80
|
+
body: dict[str, object] | None,
|
|
81
|
+
) -> McpHttpResponse:
|
|
82
|
+
headers: dict[str, str] = {"Accept": "application/json"}
|
|
83
|
+
if self._bearer_token is not None:
|
|
84
|
+
headers["Authorization"] = f"Bearer {self._bearer_token}"
|
|
85
|
+
|
|
86
|
+
with httpx.Client(transport=self._transport) as client:
|
|
87
|
+
response = client.request(
|
|
88
|
+
method,
|
|
89
|
+
base_url.rstrip("/") + path,
|
|
90
|
+
json=body,
|
|
91
|
+
headers=headers,
|
|
92
|
+
)
|
|
93
|
+
return McpHttpResponse(
|
|
94
|
+
status_code=response.status_code,
|
|
95
|
+
headers=dict(response.headers),
|
|
96
|
+
body=response.text,
|
|
97
|
+
)
|
nene2/mcp/server.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""LocalMcpServer — thin wrapper around FastMCP with NENE2 defaults.
|
|
2
|
+
|
|
3
|
+
Provides the framework scaffolding; tool registration is done in the
|
|
4
|
+
application layer (example/mcp.py) using the .tool() decorator.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import Any, Literal
|
|
9
|
+
|
|
10
|
+
from mcp.server.fastmcp import FastMCP
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LocalMcpServer:
|
|
14
|
+
"""MCP server with sensible defaults for local / stdio transport."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, name: str, instructions: str = "") -> None:
|
|
17
|
+
self._mcp = FastMCP(name, instructions=instructions)
|
|
18
|
+
|
|
19
|
+
def tool(self, description: str = "") -> Callable[[Any], Any]:
|
|
20
|
+
"""Register a function as an MCP tool."""
|
|
21
|
+
return self._mcp.tool(description=description)
|
|
22
|
+
|
|
23
|
+
def run(self, transport: Literal["stdio", "sse", "streamable-http"] = "stdio") -> None:
|
|
24
|
+
"""Start the MCP server. Blocks until the client disconnects."""
|
|
25
|
+
self._mcp.run(transport=transport)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""NENE2 middleware pipeline."""
|
|
2
|
+
|
|
3
|
+
from .domain_exception import DomainExceptionHandlerProtocol
|
|
4
|
+
from .error_handler import ErrorHandlerMiddleware
|
|
5
|
+
from .request_id import RequestIdMiddleware, request_id_var
|
|
6
|
+
from .request_logging import RequestLoggingMiddleware
|
|
7
|
+
from .request_size_limit import RequestSizeLimitMiddleware
|
|
8
|
+
from .security_headers import SecurityHeadersMiddleware
|
|
9
|
+
from .throttle import ThrottleMiddleware
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"DomainExceptionHandlerProtocol",
|
|
13
|
+
"ErrorHandlerMiddleware",
|
|
14
|
+
"RequestIdMiddleware",
|
|
15
|
+
"RequestLoggingMiddleware",
|
|
16
|
+
"RequestSizeLimitMiddleware",
|
|
17
|
+
"SecurityHeadersMiddleware",
|
|
18
|
+
"ThrottleMiddleware",
|
|
19
|
+
"request_id_var",
|
|
20
|
+
]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""DomainExceptionHandlerProtocol — delegate domain errors to typed handlers."""
|
|
2
|
+
|
|
3
|
+
from typing import Protocol, runtime_checkable
|
|
4
|
+
|
|
5
|
+
from starlette.responses import Response
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@runtime_checkable
|
|
9
|
+
class DomainExceptionHandlerProtocol(Protocol):
|
|
10
|
+
"""Map a domain exception to an HTTP response."""
|
|
11
|
+
|
|
12
|
+
def handles(self, exc: Exception) -> bool:
|
|
13
|
+
"""Return True if this handler is responsible for *exc*."""
|
|
14
|
+
...
|
|
15
|
+
|
|
16
|
+
def handle(self, exc: Exception) -> Response:
|
|
17
|
+
"""Convert *exc* to an HTTP response. Called only when handles() is True."""
|
|
18
|
+
...
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Error handler middleware.
|
|
2
|
+
|
|
3
|
+
Equivalent to PHP Nene2\\Middleware\\ErrorHandlerMiddleware.
|
|
4
|
+
Maps known exceptions to Problem Details responses; all others → 500.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from collections.abc import Awaitable, Callable, MutableMapping
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from fastapi import Request
|
|
12
|
+
from fastapi.exceptions import RequestValidationError
|
|
13
|
+
from fastapi.responses import JSONResponse
|
|
14
|
+
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
|
15
|
+
from starlette.responses import Response
|
|
16
|
+
|
|
17
|
+
from nene2.http.problem_details import problem_details_response
|
|
18
|
+
from nene2.validation.exceptions import ValidationError, ValidationException
|
|
19
|
+
|
|
20
|
+
from .domain_exception import DomainExceptionHandlerProtocol
|
|
21
|
+
|
|
22
|
+
_ASGIApp = Callable[
|
|
23
|
+
[
|
|
24
|
+
MutableMapping[str, Any],
|
|
25
|
+
Callable[[], Awaitable[MutableMapping[str, Any]]],
|
|
26
|
+
Callable[[MutableMapping[str, Any]], Awaitable[None]],
|
|
27
|
+
],
|
|
28
|
+
Awaitable[None],
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ErrorHandlerMiddleware(BaseHTTPMiddleware):
|
|
35
|
+
"""Catch-all error handler that maps exceptions to Problem Details responses."""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
app: _ASGIApp,
|
|
40
|
+
*,
|
|
41
|
+
debug: bool = False,
|
|
42
|
+
domain_handlers: list[DomainExceptionHandlerProtocol] | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
super().__init__(app)
|
|
45
|
+
self.debug = debug
|
|
46
|
+
self._domain_handlers: list[DomainExceptionHandlerProtocol] = domain_handlers or []
|
|
47
|
+
|
|
48
|
+
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
|
49
|
+
try:
|
|
50
|
+
return await call_next(request)
|
|
51
|
+
except ValidationException as exc:
|
|
52
|
+
return problem_details_response(
|
|
53
|
+
"validation-failed",
|
|
54
|
+
"Validation Failed",
|
|
55
|
+
422,
|
|
56
|
+
"The request contains invalid values.",
|
|
57
|
+
extra={"errors": [e.to_dict() for e in exc.errors]},
|
|
58
|
+
)
|
|
59
|
+
except Exception as exc:
|
|
60
|
+
for handler in self._domain_handlers:
|
|
61
|
+
if handler.handles(exc):
|
|
62
|
+
return handler.handle(exc)
|
|
63
|
+
logger.exception("Unhandled exception")
|
|
64
|
+
detail = str(exc) if self.debug else "The server encountered an unexpected condition."
|
|
65
|
+
return problem_details_response(
|
|
66
|
+
"internal-server-error",
|
|
67
|
+
"Internal Server Error",
|
|
68
|
+
500,
|
|
69
|
+
detail,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
@staticmethod
|
|
73
|
+
async def handle_validation_exception(_request: Request, exc: Exception) -> JSONResponse:
|
|
74
|
+
if not isinstance(exc, ValidationException):
|
|
75
|
+
raise TypeError(f"Expected ValidationException, got {type(exc)}")
|
|
76
|
+
return problem_details_response(
|
|
77
|
+
"validation-failed",
|
|
78
|
+
"Validation Failed",
|
|
79
|
+
422,
|
|
80
|
+
"The request contains invalid values.",
|
|
81
|
+
extra={"errors": [e.to_dict() for e in exc.errors]},
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
async def request_validation_error_handler(_request: Request, exc: Exception) -> JSONResponse:
|
|
86
|
+
"""Convert FastAPI RequestValidationError to nene2 Problem Details (422).
|
|
87
|
+
|
|
88
|
+
Register with FastAPI to replace the default Pydantic validation error format::
|
|
89
|
+
|
|
90
|
+
from fastapi.exceptions import RequestValidationError
|
|
91
|
+
from nene2.middleware.error_handler import request_validation_error_handler
|
|
92
|
+
|
|
93
|
+
app.add_exception_handler(RequestValidationError, request_validation_error_handler)
|
|
94
|
+
"""
|
|
95
|
+
if not isinstance(exc, RequestValidationError):
|
|
96
|
+
raise TypeError(f"Expected RequestValidationError, got {type(exc)}")
|
|
97
|
+
|
|
98
|
+
errors: list[ValidationError] = []
|
|
99
|
+
for raw in exc.errors():
|
|
100
|
+
loc = raw.get("loc", ())
|
|
101
|
+
field = ".".join(str(part) for part in loc if part != "body") or "request"
|
|
102
|
+
message = str(raw.get("msg", "Invalid value."))
|
|
103
|
+
code = str(raw.get("type", "invalid"))
|
|
104
|
+
errors.append(ValidationError(field=field or "request", message=message, code=code))
|
|
105
|
+
|
|
106
|
+
return problem_details_response(
|
|
107
|
+
"validation-failed",
|
|
108
|
+
"Validation Failed",
|
|
109
|
+
422,
|
|
110
|
+
"The request contains invalid values.",
|
|
111
|
+
extra={"errors": [e.to_dict() for e in errors]},
|
|
112
|
+
)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Request ID middleware.
|
|
2
|
+
|
|
3
|
+
Attaches a UUID v4 X-Request-Id to every request/response.
|
|
4
|
+
Uses contextvars so downstream code (e.g. structlog) can read the ID.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
import uuid
|
|
9
|
+
from contextvars import ContextVar
|
|
10
|
+
|
|
11
|
+
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
|
12
|
+
from starlette.requests import Request
|
|
13
|
+
from starlette.responses import Response
|
|
14
|
+
|
|
15
|
+
_REQUEST_ID_HEADER = "X-Request-Id"
|
|
16
|
+
|
|
17
|
+
# UUID v4 canonical form — 8-4-4-4-12 hex, version=4, variant=8/9/a/b
|
|
18
|
+
_UUID_V4_RE = re.compile(
|
|
19
|
+
r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
|
|
20
|
+
re.IGNORECASE,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
request_id_var: ContextVar[str] = ContextVar("request_id", default="")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _validated_request_id(value: str | None) -> str:
|
|
27
|
+
"""Return value if it is a valid UUID v4, otherwise generate a fresh one."""
|
|
28
|
+
if value and _UUID_V4_RE.match(value):
|
|
29
|
+
return value.lower()
|
|
30
|
+
return str(uuid.uuid4())
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class RequestIdMiddleware(BaseHTTPMiddleware):
|
|
34
|
+
"""Generate or forward X-Request-Id and expose it via contextvars.
|
|
35
|
+
|
|
36
|
+
Client-supplied X-Request-Id is accepted only when it matches UUID v4
|
|
37
|
+
format, preventing log injection via arbitrary header values.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
|
41
|
+
request_id = _validated_request_id(request.headers.get(_REQUEST_ID_HEADER))
|
|
42
|
+
request_id_var.set(request_id)
|
|
43
|
+
response = await call_next(request)
|
|
44
|
+
response.headers[_REQUEST_ID_HEADER] = request_id
|
|
45
|
+
return response
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Request logging middleware using structlog."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
import structlog
|
|
6
|
+
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
|
7
|
+
from starlette.requests import Request
|
|
8
|
+
from starlette.responses import Response
|
|
9
|
+
|
|
10
|
+
from .request_id import request_id_var
|
|
11
|
+
|
|
12
|
+
logger: structlog.stdlib.BoundLogger = structlog.get_logger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
|
16
|
+
"""Log each request and response with method, path, status, and duration."""
|
|
17
|
+
|
|
18
|
+
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
|
19
|
+
start = time.perf_counter()
|
|
20
|
+
structlog.contextvars.clear_contextvars()
|
|
21
|
+
structlog.contextvars.bind_contextvars(
|
|
22
|
+
request_id=request_id_var.get(),
|
|
23
|
+
method=request.method,
|
|
24
|
+
path=request.url.path,
|
|
25
|
+
)
|
|
26
|
+
logger.info("request.received")
|
|
27
|
+
response = await call_next(request)
|
|
28
|
+
duration_ms = round((time.perf_counter() - start) * 1000, 1)
|
|
29
|
+
structlog.contextvars.bind_contextvars(
|
|
30
|
+
status_code=response.status_code,
|
|
31
|
+
duration_ms=duration_ms,
|
|
32
|
+
)
|
|
33
|
+
logger.info("request.completed")
|
|
34
|
+
return response
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Request body size limit middleware.
|
|
2
|
+
|
|
3
|
+
Rejects requests whose body exceeds the configured maximum.
|
|
4
|
+
Protects against memory exhaustion from oversized payloads, including
|
|
5
|
+
chunked-transfer requests that omit the Content-Length header.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
|
9
|
+
from starlette.requests import Request
|
|
10
|
+
from starlette.responses import Response
|
|
11
|
+
|
|
12
|
+
from nene2.http.problem_details import problem_details_response
|
|
13
|
+
|
|
14
|
+
_DEFAULT_MAX_BYTES = 1_048_576 # 1 MiB
|
|
15
|
+
|
|
16
|
+
_TOO_LARGE = "Request body must not exceed {limit} bytes."
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RequestSizeLimitMiddleware(BaseHTTPMiddleware):
|
|
20
|
+
"""Reject requests whose body exceeds max_bytes.
|
|
21
|
+
|
|
22
|
+
Checks the Content-Length header first for a fast pre-flight reject,
|
|
23
|
+
then reads the actual body to catch chunked-transfer requests that
|
|
24
|
+
omit Content-Length entirely.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, app: object, *, max_bytes: int = _DEFAULT_MAX_BYTES) -> None:
|
|
28
|
+
super().__init__(app) # type: ignore[arg-type]
|
|
29
|
+
self._max_bytes = max_bytes
|
|
30
|
+
|
|
31
|
+
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
|
32
|
+
content_length = request.headers.get("Content-Length")
|
|
33
|
+
if content_length is not None:
|
|
34
|
+
try:
|
|
35
|
+
if int(content_length) > self._max_bytes:
|
|
36
|
+
return self._too_large()
|
|
37
|
+
except ValueError:
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
body = await request.body()
|
|
41
|
+
if len(body) > self._max_bytes:
|
|
42
|
+
return self._too_large()
|
|
43
|
+
|
|
44
|
+
return await call_next(request)
|
|
45
|
+
|
|
46
|
+
def _too_large(self) -> Response:
|
|
47
|
+
return problem_details_response(
|
|
48
|
+
"payload-too-large",
|
|
49
|
+
"Payload Too Large",
|
|
50
|
+
413,
|
|
51
|
+
_TOO_LARGE.format(limit=self._max_bytes),
|
|
52
|
+
)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Security headers middleware.
|
|
2
|
+
|
|
3
|
+
Adds defensive HTTP headers to every response.
|
|
4
|
+
CSP is skipped for OpenAPI documentation paths (/docs, /redoc, /openapi.json)
|
|
5
|
+
because Swagger UI loads assets from CDN which would be blocked by default-src 'self'.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
|
9
|
+
from starlette.requests import Request
|
|
10
|
+
from starlette.responses import Response
|
|
11
|
+
|
|
12
|
+
_HEADERS: dict[str, str] = {
|
|
13
|
+
"X-Content-Type-Options": "nosniff",
|
|
14
|
+
"X-Frame-Options": "DENY",
|
|
15
|
+
"Referrer-Policy": "strict-origin-when-cross-origin",
|
|
16
|
+
"Content-Security-Policy": "default-src 'self'",
|
|
17
|
+
"Permissions-Policy": "geolocation=(), microphone=()",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
_OPENAPI_PATHS: frozenset[str] = frozenset({"/docs", "/redoc", "/openapi.json"})
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
|
24
|
+
"""Attach security headers to every HTTP response.
|
|
25
|
+
|
|
26
|
+
Content-Security-Policy is omitted for OpenAPI documentation paths so that
|
|
27
|
+
Swagger UI and ReDoc (which load assets from CDN) continue to work in development.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
|
31
|
+
response = await call_next(request)
|
|
32
|
+
is_openapi_path = request.url.path in _OPENAPI_PATHS
|
|
33
|
+
for header, value in _HEADERS.items():
|
|
34
|
+
if is_openapi_path and header == "Content-Security-Policy":
|
|
35
|
+
continue
|
|
36
|
+
response.headers[header] = value
|
|
37
|
+
return response
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Fixed-window rate limiting middleware.
|
|
2
|
+
|
|
3
|
+
Tracks request counts per client IP in an in-memory dict.
|
|
4
|
+
Exceeding the limit returns 429 with a Retry-After header.
|
|
5
|
+
|
|
6
|
+
.. warning::
|
|
7
|
+
``X-Forwarded-For`` is trusted as-is when present. In environments
|
|
8
|
+
**without** a trusted reverse proxy this header can be spoofed by clients,
|
|
9
|
+
allowing them to bypass the rate limit. Deploy behind a proxy that strips
|
|
10
|
+
or overwrites the header (e.g. nginx ``proxy_set_header X-Forwarded-For
|
|
11
|
+
$remote_addr``) before enabling this middleware in production.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
|
|
17
|
+
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
|
18
|
+
from starlette.requests import Request
|
|
19
|
+
from starlette.responses import Response
|
|
20
|
+
|
|
21
|
+
from nene2.http.problem_details import problem_details_response
|
|
22
|
+
|
|
23
|
+
_DEFAULT_LIMIT = 60
|
|
24
|
+
_DEFAULT_WINDOW = 60 # seconds
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ThrottleMiddleware(BaseHTTPMiddleware):
|
|
28
|
+
"""Fixed-window rate limiter keyed by client IP."""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
app: object,
|
|
33
|
+
*,
|
|
34
|
+
limit: int = _DEFAULT_LIMIT,
|
|
35
|
+
window: int = _DEFAULT_WINDOW,
|
|
36
|
+
) -> None:
|
|
37
|
+
super().__init__(app) # type: ignore[arg-type]
|
|
38
|
+
self._limit = limit
|
|
39
|
+
self._window = window
|
|
40
|
+
self._counts: dict[str, tuple[int, float]] = {}
|
|
41
|
+
self._lock = threading.Lock()
|
|
42
|
+
|
|
43
|
+
def _client_key(self, request: Request) -> str:
|
|
44
|
+
forwarded = request.headers.get("X-Forwarded-For")
|
|
45
|
+
if forwarded:
|
|
46
|
+
return forwarded.split(",")[0].strip()
|
|
47
|
+
return request.client.host if request.client else "unknown"
|
|
48
|
+
|
|
49
|
+
def _is_allowed(self, key: str) -> tuple[bool, int]:
|
|
50
|
+
now = time.monotonic()
|
|
51
|
+
with self._lock:
|
|
52
|
+
count, window_start = self._counts.get(key, (0, now))
|
|
53
|
+
if now - window_start >= self._window:
|
|
54
|
+
count, window_start = 0, now
|
|
55
|
+
count += 1
|
|
56
|
+
self._counts[key] = (count, window_start)
|
|
57
|
+
remaining = max(0, self._window - int(now - window_start))
|
|
58
|
+
return count <= self._limit, remaining
|
|
59
|
+
|
|
60
|
+
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
|
61
|
+
key = self._client_key(request)
|
|
62
|
+
allowed, retry_after = self._is_allowed(key)
|
|
63
|
+
if not allowed:
|
|
64
|
+
response = problem_details_response(
|
|
65
|
+
"too-many-requests",
|
|
66
|
+
"Too Many Requests",
|
|
67
|
+
429,
|
|
68
|
+
f"Rate limit exceeded. Retry after {retry_after} seconds.",
|
|
69
|
+
)
|
|
70
|
+
response.headers["Retry-After"] = str(retry_after)
|
|
71
|
+
return response
|
|
72
|
+
return await call_next(request)
|
nene2/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Structural type contracts for UseCase and AsyncUseCase.
|
|
2
|
+
|
|
3
|
+
UseCaseProtocol — synchronous execute(input_) -> output
|
|
4
|
+
AsyncUseCaseProtocol — async execute(input_) -> output (awaitable)
|
|
5
|
+
|
|
6
|
+
Both use Python 3.12 generic syntax; any class with a matching
|
|
7
|
+
execute() signature satisfies them structurally (no inheritance needed).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from typing import Protocol, runtime_checkable
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@runtime_checkable
|
|
14
|
+
class UseCaseProtocol[I, O](Protocol):
|
|
15
|
+
"""Synchronous use-case contract."""
|
|
16
|
+
|
|
17
|
+
def execute(self, input_: I) -> O: ...
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@runtime_checkable
|
|
21
|
+
class AsyncUseCaseProtocol[I, O](Protocol):
|
|
22
|
+
"""Asynchronous use-case contract — execute must be a coroutine."""
|
|
23
|
+
|
|
24
|
+
async def execute(self, input_: I) -> O: ...
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Validation exceptions.
|
|
2
|
+
|
|
3
|
+
Equivalent to PHP Nene2\\Validation\\ValidationException and ValidationError.
|
|
4
|
+
Raised by handlers or use-cases to produce a 422 validation-failed Problem Details response.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True)
|
|
11
|
+
class ValidationError:
|
|
12
|
+
"""A single field-level validation failure."""
|
|
13
|
+
|
|
14
|
+
field: str
|
|
15
|
+
message: str
|
|
16
|
+
code: str
|
|
17
|
+
|
|
18
|
+
def __post_init__(self) -> None:
|
|
19
|
+
if not self.field or not self.message or not self.code:
|
|
20
|
+
raise ValueError("field, message, and code must be non-empty strings")
|
|
21
|
+
|
|
22
|
+
def to_dict(self) -> dict[str, str]:
|
|
23
|
+
return {"field": self.field, "message": self.message, "code": self.code}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ValidationException(Exception):
|
|
27
|
+
"""Raised when one or more validation rules fail.
|
|
28
|
+
|
|
29
|
+
ErrorHandlerMiddleware maps this to a 422 validation-failed Problem Details response.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, errors: list[ValidationError]) -> None:
|
|
33
|
+
super().__init__("Validation failed")
|
|
34
|
+
self.errors = errors
|