Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/api/rest-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -316,8 +316,8 @@ Errors follow RFC 7807 Problem Details:
{
"type": "/errors/validation-error",
"title": "Request Validation Failed",
"status": 400,
"detail": "count must be positive (got: -10)",
"status": 422,
"detail": "Request validation error details",
"instance": "/rates/from"
}
```
Expand Down
22 changes: 3 additions & 19 deletions mt5api/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,7 @@
get_configured_api_log_level,
get_configured_api_port,
)
from .constants import (
API_APP_IMPORT,
DEFAULT_API_PORT,
MAX_API_PORT,
)
from .constants import API_APP_IMPORT


def _get_host() -> str:
Expand All @@ -33,19 +29,7 @@ def _get_port() -> int:
Returns:
Port number for the API server.
"""
raw_port = get_configured_api_port()
if raw_port is None:
return DEFAULT_API_PORT

try:
port_value = int(raw_port)
except ValueError:
return DEFAULT_API_PORT

if not 1 <= port_value <= MAX_API_PORT:
return DEFAULT_API_PORT

return port_value
return get_configured_api_port()


def _get_log_level() -> str:
Expand All @@ -54,7 +38,7 @@ def _get_log_level() -> str:
Returns:
Log level string for uvicorn.
"""
return get_configured_api_log_level().lower()
return get_configured_api_log_level()


def main() -> None:
Expand Down
55 changes: 49 additions & 6 deletions mt5api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from .constants import (
DEFAULT_API_HOST,
DEFAULT_API_LOG_LEVEL,
DEFAULT_API_PORT,
DEFAULT_API_ROUTER_PREFIX,
DEFAULT_MAX_MARKET_BOOK_SUBSCRIPTIONS,
ENV_MT5API_HOST,
Expand All @@ -16,9 +17,21 @@
ENV_MT5API_PORT,
ENV_MT5API_ROUTER_PREFIX,
ENV_MT5API_SECRET_KEY,
MAX_API_PORT,
)

_VALID_API_ROUTER_PREFIX_PATTERN = re.compile(r"^[A-Za-z0-9_-]+(?:/[A-Za-z0-9_-]+)*$")
_VALID_UVICORN_LOG_LEVELS = {"critical", "error", "warning", "info", "debug", "trace"}
_LOGGING_LOG_LEVEL_BY_API_LEVEL = {
"critical": "CRITICAL",
"error": "ERROR",
"warning": "WARNING",
"info": "INFO",
"debug": "DEBUG",
"trace": "DEBUG",
}
_INVALID_MT5API_PORT_ERROR = "Invalid MT5API_PORT"
_INVALID_MT5API_LOG_LEVEL_ERROR = "Invalid MT5API_LOG_LEVEL"
_INVALID_MT5API_ROUTER_PREFIX_ERROR = "Invalid MT5API_ROUTER_PREFIX"
_INVALID_MT5API_MAX_MARKET_BOOK_SUBSCRIPTIONS_ERROR = (
"Invalid MT5API_MAX_MARKET_BOOK_SUBSCRIPTIONS"
Expand Down Expand Up @@ -59,22 +72,52 @@ def get_configured_api_host() -> str:
return os.getenv(ENV_MT5API_HOST, DEFAULT_API_HOST)


def get_configured_api_port() -> str | None:
"""Get the configured API port string.
def get_configured_api_port() -> int:
"""Get the configured API port.

Returns:
Raw port string from the environment, or ``None`` if unset.
Port number for the API server.

Raises:
ValueError: If the configured port is not an integer in the TCP port range.
"""
return os.getenv(ENV_MT5API_PORT)
raw_port = os.getenv(ENV_MT5API_PORT)
if raw_port is None:
return DEFAULT_API_PORT

try:
port = int(raw_port)
except ValueError as error:
raise ValueError(_INVALID_MT5API_PORT_ERROR) from error

if not 1 <= port <= MAX_API_PORT:
raise ValueError(_INVALID_MT5API_PORT_ERROR)

return port


def get_configured_api_log_level() -> str:
"""Get the configured API log level.

Returns:
Log level string from configuration.
Valid uvicorn log level string from configuration.

Raises:
ValueError: If the configured log level is unsupported.
"""
log_level = os.getenv(ENV_MT5API_LOG_LEVEL, DEFAULT_API_LOG_LEVEL).strip().lower()
if log_level not in _VALID_UVICORN_LOG_LEVELS:
raise ValueError(_INVALID_MT5API_LOG_LEVEL_ERROR)
return log_level


def get_configured_python_log_level() -> str:
"""Get a stdlib logging-compatible log level for the API logger.

Returns:
Python logging level name derived from the configured API log level.
"""
return os.getenv(ENV_MT5API_LOG_LEVEL, DEFAULT_API_LOG_LEVEL)
return _LOGGING_LOG_LEVEL_BY_API_LEVEL[get_configured_api_log_level()]


def get_configured_api_router_prefix() -> str:
Expand Down
2 changes: 1 addition & 1 deletion mt5api/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"Provides market data, account information, trading history, calculations, "
"and safe operational endpoints via HTTP."
)
API_VERSION = "1.0.0"
API_VERSION = "1.0.1"
API_DOCS_URL = "/docs"
API_REDOC_URL = "/redoc"
API_OPENAPI_URL = "/openapi.json"
Expand Down
21 changes: 7 additions & 14 deletions mt5api/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,12 @@ def shutdown_mt5_client() -> None:
async def replace_mt5_client(config: Mt5Config) -> Mt5DataClient:
"""Swap the MT5 client singleton with a new connection.

Constructs and initializes the new ``Mt5DataClient`` BEFORE touching the
singleton so a failed reconnect leaves the existing client in place
instead of disconnecting the operator from a working terminal. The new
client is installed atomically; the old client (if any) is shut down on
a best-effort basis after the swap. Callers MUST hold
``get_mt5_client_lock`` so concurrent reconnect requests do not race.
Constructs and initializes the new ``Mt5DataClient`` before replacing the
singleton reference. The MetaTrader5 Python module has process-global
connection state, so the previous client object must not be shut down after
successful reconnect because that would close the connection just opened by
the new client. Callers MUST hold ``get_mt5_client_lock`` so concurrent
reconnect requests do not race.

The raised ``RuntimeError`` deliberately omits the underlying exception
text from its message because third-party ``pdmt5``/MetaTrader5
Expand All @@ -101,7 +101,7 @@ async def replace_mt5_client(config: Mt5Config) -> Mt5DataClient:

Raises:
RuntimeError: If the new MT5 client cannot be constructed or
initialized. The previously installed client is preserved.
initialized.
"""
global _mt5_client # noqa: PLW0603

Expand All @@ -113,15 +113,8 @@ async def replace_mt5_client(config: Mt5Config) -> Mt5DataClient:
error_message = "Failed to initialize MT5 client"
raise RuntimeError(error_message) from e

old_client = _mt5_client
_mt5_client = new_client

if old_client is not None:
try:
await asyncio.to_thread(old_client.shutdown)
except Exception:
logger.exception("Failed to shutdown previous MT5 client")

return new_client


Expand Down
37 changes: 35 additions & 2 deletions mt5api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@

from .auth import is_auth_enabled
from .config import (
get_configured_api_log_level,
get_configured_api_router_prefix,
get_configured_max_market_book_subscriptions,
get_configured_python_log_level,
)
from .constants import (
ACTIVE_MARKET_BOOK_SUBSCRIPTIONS_STATE_KEY,
Expand All @@ -31,6 +31,7 @@
)
from .dependencies import release_market_book_subscriptions, shutdown_mt5_client
from .middleware import add_middleware
from .models import ErrorResponse
from .routers import (
account,
calc,
Expand Down Expand Up @@ -65,7 +66,7 @@ def format(self, record: logging.LogRecord) -> str:

def _configure_logging() -> None:
"""Configure structured logging for the API."""
log_level = get_configured_api_log_level().upper()
log_level = get_configured_python_log_level()

handler = logging.StreamHandler()
handler.setFormatter(_JsonFormatter())
Expand Down Expand Up @@ -96,6 +97,37 @@ def _strip_auth_from_openapi(openapi_schema: dict[str, Any]) -> None:
operation.pop("security", None)


def _patch_validation_error_responses(openapi_schema: dict[str, Any]) -> None:
"""Advertise RFC 7807 Problem Details for request validation failures."""
components = openapi_schema.setdefault("components", {})
if not isinstance(components, dict):
return

schemas = components.setdefault("schemas", {})
if isinstance(schemas, dict):
schemas["ErrorResponse"] = ErrorResponse.model_json_schema(
ref_template="#/components/schemas/{model}",
)
schemas.pop("HTTPValidationError", None)
schemas.pop("ValidationError", None)

error_response = {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {"$ref": "#/components/schemas/ErrorResponse"},
},
},
}

for methods in openapi_schema.get("paths", {}).values():
if not isinstance(methods, dict):
continue
for operation in methods.values():
if isinstance(operation, dict) and "422" in operation.get("responses", {}):
operation["responses"]["422"] = error_response


def _build_openapi_schema(app: FastAPI) -> dict[str, Any]:
"""Build OpenAPI schema for the current authentication mode.

Expand All @@ -113,6 +145,7 @@ def _build_openapi_schema(app: FastAPI) -> dict[str, Any]:
)
if not is_auth_enabled():
_strip_auth_from_openapi(openapi_schema)
_patch_validation_error_responses(openapi_schema)

app.openapi_schema = openapi_schema
return openapi_schema
Expand Down
84 changes: 82 additions & 2 deletions mt5api/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,56 @@

import logging
import time
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any

from fastapi import Request, status
from fastapi import HTTPException, Request, status
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from pdmt5.mt5 import Mt5RuntimeError
from pydantic import ValidationError

from .models import ErrorResponse

if TYPE_CHECKING:
from collections.abc import Sequence

from fastapi import FastAPI
from starlette.middleware.base import RequestResponseEndpoint
from starlette.responses import Response

logger = logging.getLogger(__name__)


def _format_validation_error_location(location: Sequence[Any]) -> str:
"""Format a Pydantic error location for display.

Args:
location: Pydantic validation error location tuple.

Returns:
Human-readable field path without the request-body prefix.
"""
parts = [str(part) for part in location if str(part) != "body"]
return ".".join(parts) if parts else "request"


def _format_request_validation_detail(exc: RequestValidationError) -> str:
"""Build a sanitized validation detail string without echoing request input.

Args:
exc: FastAPI request validation error.

Returns:
Validation summary containing field paths and messages only.
"""
details: list[str] = []
for error in exc.errors():
location = _format_validation_error_location(error.get("loc", ()))
message = str(error.get("msg", "Invalid value"))
details.append(f"{location}: {message}")
return "; ".join(details) if details else "Request validation failed"


def _create_error_response(
error_type: str,
title: str,
Expand Down Expand Up @@ -162,11 +195,58 @@ async def logging_middleware(
return response


def http_exception_handler(
request: Request,
exc: HTTPException,
) -> JSONResponse:
"""Convert FastAPI HTTP exceptions to flat RFC 7807 responses.

Returns:
Problem Details JSON response.
"""
if isinstance(exc.detail, dict):
detail = exc.detail.get("detail", str(exc.detail))
error_type = str(exc.detail.get("type", "/errors/http-error"))
title = str(exc.detail.get("title", "HTTP Error"))
else:
detail = str(exc.detail)
error_type = "/errors/http-error"
title = "HTTP Error"

return _create_error_response(
error_type,
title,
exc.status_code,
detail,
str(request.url),
)


def request_validation_exception_handler(
request: Request,
exc: RequestValidationError,
) -> JSONResponse:
"""Convert FastAPI request validation failures to RFC 7807 responses.

Returns:
Problem Details JSON response.
"""
return _create_error_response(
"/errors/validation-error",
"Request Validation Failed",
status.HTTP_422_UNPROCESSABLE_ENTITY,
_format_request_validation_detail(exc),
str(request.url),
)
Comment thread
dceoy marked this conversation as resolved.


def add_middleware(app: FastAPI) -> None:
"""Add middleware and error handlers to the FastAPI application.

Args:
app: FastAPI application instance.
"""
app.exception_handler(HTTPException)(http_exception_handler)
app.exception_handler(RequestValidationError)(request_validation_exception_handler)
app.middleware("http")(error_handler_middleware)
app.middleware("http")(logging_middleware)
Loading