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/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
+ ]
@@ -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,5 @@
1
+ """UseCase contracts — synchronous and asynchronous Protocol definitions."""
2
+
3
+ from .protocols import AsyncUseCaseProtocol, UseCaseProtocol
4
+
5
+ __all__ = ["AsyncUseCaseProtocol", "UseCaseProtocol"]
@@ -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,5 @@
1
+ """Validation exceptions — equivalent to PHP ValidationException / ValidationError."""
2
+
3
+ from .exceptions import ValidationError, ValidationException
4
+
5
+ __all__ = ["ValidationError", "ValidationException"]
@@ -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