diff --git a/configuration/config.json b/configuration/config.json index 1e06fb0..6003d43 100644 --- a/configuration/config.json +++ b/configuration/config.json @@ -17,5 +17,11 @@ "ssl_keyfile": "key.pem", "ssl_certfile": "cert.pem", "days_valid": 365 + }, + "json_response": { + "ensure_ascii": false, + "allow_nan": false, + "indent": null, + "media_type": "application/json; charset=utf-8" } } diff --git a/python_template_server/models.py b/python_template_server/models.py index 7657378..d34c72c 100644 --- a/python_template_server/models.py +++ b/python_template_server/models.py @@ -1,9 +1,12 @@ """Pydantic models for the server.""" +import json from datetime import datetime from enum import IntEnum, StrEnum, auto from pathlib import Path +from typing import Any +from fastapi.responses import JSONResponse from pydantic import BaseModel, Field @@ -61,6 +64,15 @@ def ssl_cert_file_path(self) -> Path: return Path(self.directory) / self.ssl_certfile +class JSONResponseConfigModel(BaseModel): + """JSON response rendering configuration model.""" + + ensure_ascii: bool = Field(default=False, description="Whether to escape non-ASCII characters") + allow_nan: bool = Field(default=False, description="Whether to allow NaN values in JSON") + indent: int | None = Field(default=None, description="Indentation level for pretty-printing (None for compact)") + media_type: str = Field(default="application/json; charset=utf-8", description="Media type for JSON responses") + + class TemplateServerConfig(BaseModel): """Template server configuration.""" @@ -68,6 +80,7 @@ class TemplateServerConfig(BaseModel): security: SecurityConfigModel = Field(default_factory=SecurityConfigModel) rate_limit: RateLimitConfigModel = Field(default_factory=RateLimitConfigModel) certificate: CertificateConfigModel = Field(default_factory=CertificateConfigModel) + json_response: JSONResponseConfigModel = Field(default_factory=JSONResponseConfigModel) def save_to_file(self, filepath: Path) -> None: """Save the configuration to a JSON file. @@ -79,6 +92,32 @@ def save_to_file(self, filepath: Path) -> None: # API Response Models +class CustomJSONResponse(JSONResponse): + """Custom JSONResponse with configurable rendering options.""" + + _ensure_ascii: bool = False + _allow_nan: bool = False + _indent: int | None = None + + @classmethod + def configure(cls, json_response_config: JSONResponseConfigModel) -> None: + """Configure class-level JSON rendering options.""" + cls._ensure_ascii = json_response_config.ensure_ascii + cls._allow_nan = json_response_config.allow_nan + cls._indent = json_response_config.indent + cls.media_type = json_response_config.media_type + + def render(self, content: Any) -> bytes: # noqa: ANN401 + """Render content to JSON with configured options.""" + return json.dumps( + content, + ensure_ascii=self._ensure_ascii, + allow_nan=self._allow_nan, + indent=self._indent, + separators=(",", ":"), + ).encode("utf-8") + + class ResponseCode(IntEnum): """HTTP response codes for API endpoints.""" diff --git a/python_template_server/template_server.py b/python_template_server/template_server.py index ad137ef..0776366 100644 --- a/python_template_server/template_server.py +++ b/python_template_server/template_server.py @@ -12,7 +12,6 @@ import uvicorn from fastapi import FastAPI, HTTPException, Request, Security -from fastapi.responses import JSONResponse from fastapi.security import APIKeyHeader from prometheus_client import Counter, Gauge from prometheus_fastapi_instrumentator import Instrumentator @@ -26,7 +25,13 @@ from python_template_server.constants import API_KEY_HEADER_NAME, API_PREFIX, CONFIG_FILE_PATH, PACKAGE_NAME from python_template_server.logging_setup import setup_logging from python_template_server.middleware import RequestLoggingMiddleware, SecurityHeadersMiddleware -from python_template_server.models import GetHealthResponse, ResponseCode, ServerHealthStatus, TemplateServerConfig +from python_template_server.models import ( + CustomJSONResponse, + GetHealthResponse, + ResponseCode, + ServerHealthStatus, + TemplateServerConfig, +) setup_logging() logger = logging.getLogger(__name__) @@ -63,6 +68,8 @@ def __init__( self.config_filepath = config_filepath self.config = config or self.load_config(config_filepath) + CustomJSONResponse.configure(self.config.json_response) + self.package_metadata = metadata(self.package_name) self.app = FastAPI( title=self.package_metadata["Name"], @@ -70,6 +77,7 @@ def __init__( version=self.package_metadata["Version"], root_path=self.api_prefix, lifespan=self.lifespan, + default_response_class=CustomJSONResponse, ) self.api_key_header = APIKeyHeader(name=self.api_key_header_name, auto_error=False) @@ -177,7 +185,7 @@ def _setup_security_headers(self) -> None: self.config.security.content_security_policy, ) - async def _rate_limit_exception_handler(self, request: Request, exc: RateLimitExceeded) -> JSONResponse: + async def _rate_limit_exception_handler(self, request: Request, exc: RateLimitExceeded) -> CustomJSONResponse: """Handle rate limit exceeded exceptions and track metrics. :param Request request: The incoming HTTP request @@ -187,7 +195,7 @@ async def _rate_limit_exception_handler(self, request: Request, exc: RateLimitEx self.rate_limit_exceeded_counter.labels(endpoint=request.url.path).inc() # Return JSON response with 429 status - return JSONResponse( + return CustomJSONResponse( status_code=429, content={"detail": "Rate limit exceeded"}, headers={"Retry-After": str(exc.retry_after)} if hasattr(exc, "retry_after") else {}, diff --git a/tests/conftest.py b/tests/conftest.py index 05409eb..4ee8275 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,6 +8,7 @@ from python_template_server.models import ( CertificateConfigModel, + JSONResponseConfigModel, RateLimitConfigModel, SecurityConfigModel, ServerConfigModel, @@ -125,28 +126,45 @@ def mock_certificate_config_dict() -> dict: } +@pytest.fixture +def mock_json_response_config_dict() -> dict: + """Provide a mock JSON response configuration dictionary.""" + return { + "ensure_ascii": False, + "allow_nan": False, + "indent": None, + "media_type": "application/json; charset=utf-8", + } + + @pytest.fixture def mock_server_config(mock_server_config_dict: dict) -> ServerConfigModel: """Provide a mock ServerConfigModel instance.""" - return ServerConfigModel(**mock_server_config_dict) + return ServerConfigModel.model_validate(mock_server_config_dict) @pytest.fixture def mock_security_config(mock_security_config_dict: dict) -> SecurityConfigModel: """Provide a mock SecurityConfigModel instance.""" - return SecurityConfigModel(**mock_security_config_dict) + return SecurityConfigModel.model_validate(mock_security_config_dict) @pytest.fixture def mock_rate_limit_config(mock_rate_limit_config_dict: dict) -> RateLimitConfigModel: """Provide a mock RateLimitConfigModel instance.""" - return RateLimitConfigModel(**mock_rate_limit_config_dict) + return RateLimitConfigModel.model_validate(mock_rate_limit_config_dict) @pytest.fixture def mock_certificate_config(mock_certificate_config_dict: dict) -> CertificateConfigModel: """Provide a mock CertificateConfigModel instance.""" - return CertificateConfigModel(**mock_certificate_config_dict) + return CertificateConfigModel.model_validate(mock_certificate_config_dict) + + +@pytest.fixture +def mock_json_response_config(mock_json_response_config_dict: dict) -> JSONResponseConfigModel: + """Provide a mock JSONResponseConfigModel instance.""" + return JSONResponseConfigModel.model_validate(mock_json_response_config_dict) @pytest.fixture @@ -155,6 +173,7 @@ def mock_template_server_config( mock_security_config: SecurityConfigModel, mock_rate_limit_config: RateLimitConfigModel, mock_certificate_config: CertificateConfigModel, + mock_json_response_config: JSONResponseConfigModel, ) -> TemplateServerConfig: """Provide a mock TemplateServerConfig instance.""" return TemplateServerConfig( @@ -162,4 +181,5 @@ def mock_template_server_config( security=mock_security_config, rate_limit=mock_rate_limit_config, certificate=mock_certificate_config, + json_response=mock_json_response_config, ) diff --git a/tests/test_models.py b/tests/test_models.py index 24c74b4..31aaef8 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -8,7 +8,9 @@ from python_template_server.models import ( BaseResponse, CertificateConfigModel, + CustomJSONResponse, GetHealthResponse, + JSONResponseConfigModel, RateLimitConfigModel, ResponseCode, SecurityConfigModel, @@ -87,6 +89,16 @@ def test_days_valid_field( CertificateConfigModel(**invalid_config_data) +class TestJSONResponseConfigModel: + """Unit tests for the JSONResponseConfigModel class.""" + + def test_model_dump( + self, mock_json_response_config_dict: dict, mock_json_response_config: JSONResponseConfigModel + ) -> None: + """Test the model_dump method.""" + assert mock_json_response_config.model_dump() == mock_json_response_config_dict + + class TestTemplateServerConfig: """Unit tests for the TemplateServerConfig class.""" @@ -97,6 +109,7 @@ def test_model_dump( mock_security_config_dict: dict, mock_rate_limit_config_dict: dict, mock_certificate_config_dict: dict, + mock_json_response_config_dict: dict, ) -> None: """Test the model_dump method.""" expected_dict = { @@ -104,6 +117,7 @@ def test_model_dump( "security": mock_security_config_dict, "rate_limit": mock_rate_limit_config_dict, "certificate": mock_certificate_config_dict, + "json_response": mock_json_response_config_dict, } assert mock_template_server_config.model_dump() == expected_dict @@ -119,6 +133,59 @@ def test_save_to_file( # API Response Models +class TestCustomJSONResponse: + """Unit tests for the CustomJSONResponse class.""" + + def test_configure_method(self, mock_json_response_config: JSONResponseConfigModel) -> None: + """Test the configure class method.""" + CustomJSONResponse.configure(mock_json_response_config) + + assert CustomJSONResponse._ensure_ascii == mock_json_response_config.ensure_ascii + assert CustomJSONResponse._allow_nan == mock_json_response_config.allow_nan + assert CustomJSONResponse._indent == mock_json_response_config.indent + assert CustomJSONResponse.media_type == mock_json_response_config.media_type + + def test_render_with_unicode(self, mock_json_response_config: JSONResponseConfigModel) -> None: + """Test rendering JSON with Unicode characters (emojis).""" + CustomJSONResponse.configure(mock_json_response_config) + response = CustomJSONResponse(content={"message": "Hello 👋 World 🌍"}) + + rendered = response.render({"message": "Hello 👋 World 🌍"}) + assert b"Hello \\ud83d\\udc4b World" not in rendered # Should NOT be escaped + assert "👋".encode() in rendered # Should preserve emoji + assert "🌍".encode() in rendered + + def test_render_with_ensure_ascii_true(self) -> None: + """Test rendering with ensure_ascii=True.""" + config = JSONResponseConfigModel(ensure_ascii=True) + CustomJSONResponse.configure(config) + response = CustomJSONResponse(content={"message": "Hello 👋"}) + + rendered = response.render({"message": "Hello 👋"}) + # With ensure_ascii=True, Unicode should be escaped + assert b"\\ud83d\\udc4b" in rendered or b"\\u" in rendered + + def test_render_with_indent(self) -> None: + """Test rendering with indentation.""" + config = JSONResponseConfigModel(indent=2) + CustomJSONResponse.configure(config) + response = CustomJSONResponse(content={"key": "value"}) + + rendered = response.render({"key": "value"}) + # With indent, output should have newlines and spaces + assert b"\n" in rendered + assert b" " in rendered + + def test_render_compact(self, mock_json_response_config: JSONResponseConfigModel) -> None: + """Test rendering in compact mode (no indent).""" + CustomJSONResponse.configure(mock_json_response_config) + response = CustomJSONResponse(content={"key": "value", "number": 42}) + + rendered = response.render({"key": "value", "number": 42}) + # Compact mode should use "," separator without spaces after + assert rendered == b'{"key":"value","number":42}' + + class TestResponseCode: """Unit tests for the ResponseCode enum.""" diff --git a/tests/test_template_server.py b/tests/test_template_server.py index 17800e9..e191c72 100644 --- a/tests/test_template_server.py +++ b/tests/test_template_server.py @@ -22,6 +22,7 @@ from python_template_server.middleware import RequestLoggingMiddleware, SecurityHeadersMiddleware from python_template_server.models import ( BaseResponse, + CustomJSONResponse, ResponseCode, ServerHealthStatus, TemplateServerConfig, @@ -124,6 +125,24 @@ def test_request_middleware_added(self, mock_template_server: TemplateServer) -> assert RequestLoggingMiddleware in middlewares assert SecurityHeadersMiddleware in middlewares + def test_json_response_configured( + self, mock_template_server: TemplateServer, mock_template_server_config: TemplateServerConfig + ) -> None: + """Test that CustomJSONResponse is properly configured during initialization.""" + # Verify CustomJSONResponse class variables are set correctly + assert CustomJSONResponse._ensure_ascii == mock_template_server_config.json_response.ensure_ascii + assert CustomJSONResponse._allow_nan == mock_template_server_config.json_response.allow_nan + assert CustomJSONResponse._indent == mock_template_server_config.json_response.indent + assert CustomJSONResponse.media_type == mock_template_server_config.json_response.media_type + + # Test that CustomJSONResponse renders correctly with configured settings + response = CustomJSONResponse(content={"test": "data", "emoji": "👋"}) + rendered = response.render({"test": "data", "emoji": "👋"}) + + # With ensure_ascii=False, emojis should be preserved + assert "👋".encode() in rendered + assert b'"test":"data"' in rendered # Compact format (no spaces) + class TestLoadConfig: """Tests for the load_config function."""