Skip to content

Commit aee26bd

Browse files
Add custom JSON response configuration and implementation (#9)
1 parent b14f0b3 commit aee26bd

File tree

6 files changed

+167
-8
lines changed

6 files changed

+167
-8
lines changed

configuration/config.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,11 @@
1717
"ssl_keyfile": "key.pem",
1818
"ssl_certfile": "cert.pem",
1919
"days_valid": 365
20+
},
21+
"json_response": {
22+
"ensure_ascii": false,
23+
"allow_nan": false,
24+
"indent": null,
25+
"media_type": "application/json; charset=utf-8"
2026
}
2127
}

python_template_server/models.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
"""Pydantic models for the server."""
22

3+
import json
34
from datetime import datetime
45
from enum import IntEnum, StrEnum, auto
56
from pathlib import Path
7+
from typing import Any
68

9+
from fastapi.responses import JSONResponse
710
from pydantic import BaseModel, Field
811

912

@@ -61,13 +64,23 @@ def ssl_cert_file_path(self) -> Path:
6164
return Path(self.directory) / self.ssl_certfile
6265

6366

67+
class JSONResponseConfigModel(BaseModel):
68+
"""JSON response rendering configuration model."""
69+
70+
ensure_ascii: bool = Field(default=False, description="Whether to escape non-ASCII characters")
71+
allow_nan: bool = Field(default=False, description="Whether to allow NaN values in JSON")
72+
indent: int | None = Field(default=None, description="Indentation level for pretty-printing (None for compact)")
73+
media_type: str = Field(default="application/json; charset=utf-8", description="Media type for JSON responses")
74+
75+
6476
class TemplateServerConfig(BaseModel):
6577
"""Template server configuration."""
6678

6779
server: ServerConfigModel = Field(default_factory=ServerConfigModel)
6880
security: SecurityConfigModel = Field(default_factory=SecurityConfigModel)
6981
rate_limit: RateLimitConfigModel = Field(default_factory=RateLimitConfigModel)
7082
certificate: CertificateConfigModel = Field(default_factory=CertificateConfigModel)
83+
json_response: JSONResponseConfigModel = Field(default_factory=JSONResponseConfigModel)
7184

7285
def save_to_file(self, filepath: Path) -> None:
7386
"""Save the configuration to a JSON file.
@@ -79,6 +92,32 @@ def save_to_file(self, filepath: Path) -> None:
7992

8093

8194
# API Response Models
95+
class CustomJSONResponse(JSONResponse):
96+
"""Custom JSONResponse with configurable rendering options."""
97+
98+
_ensure_ascii: bool = False
99+
_allow_nan: bool = False
100+
_indent: int | None = None
101+
102+
@classmethod
103+
def configure(cls, json_response_config: JSONResponseConfigModel) -> None:
104+
"""Configure class-level JSON rendering options."""
105+
cls._ensure_ascii = json_response_config.ensure_ascii
106+
cls._allow_nan = json_response_config.allow_nan
107+
cls._indent = json_response_config.indent
108+
cls.media_type = json_response_config.media_type
109+
110+
def render(self, content: Any) -> bytes: # noqa: ANN401
111+
"""Render content to JSON with configured options."""
112+
return json.dumps(
113+
content,
114+
ensure_ascii=self._ensure_ascii,
115+
allow_nan=self._allow_nan,
116+
indent=self._indent,
117+
separators=(",", ":"),
118+
).encode("utf-8")
119+
120+
82121
class ResponseCode(IntEnum):
83122
"""HTTP response codes for API endpoints."""
84123

python_template_server/template_server.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212

1313
import uvicorn
1414
from fastapi import FastAPI, HTTPException, Request, Security
15-
from fastapi.responses import JSONResponse
1615
from fastapi.security import APIKeyHeader
1716
from prometheus_client import Counter, Gauge
1817
from prometheus_fastapi_instrumentator import Instrumentator
@@ -26,7 +25,13 @@
2625
from python_template_server.constants import API_KEY_HEADER_NAME, API_PREFIX, CONFIG_FILE_PATH, PACKAGE_NAME
2726
from python_template_server.logging_setup import setup_logging
2827
from python_template_server.middleware import RequestLoggingMiddleware, SecurityHeadersMiddleware
29-
from python_template_server.models import GetHealthResponse, ResponseCode, ServerHealthStatus, TemplateServerConfig
28+
from python_template_server.models import (
29+
CustomJSONResponse,
30+
GetHealthResponse,
31+
ResponseCode,
32+
ServerHealthStatus,
33+
TemplateServerConfig,
34+
)
3035

3136
setup_logging()
3237
logger = logging.getLogger(__name__)
@@ -63,13 +68,16 @@ def __init__(
6368
self.config_filepath = config_filepath
6469
self.config = config or self.load_config(config_filepath)
6570

71+
CustomJSONResponse.configure(self.config.json_response)
72+
6673
self.package_metadata = metadata(self.package_name)
6774
self.app = FastAPI(
6875
title=self.package_metadata["Name"],
6976
description=self.package_metadata["Summary"],
7077
version=self.package_metadata["Version"],
7178
root_path=self.api_prefix,
7279
lifespan=self.lifespan,
80+
default_response_class=CustomJSONResponse,
7381
)
7482
self.api_key_header = APIKeyHeader(name=self.api_key_header_name, auto_error=False)
7583

@@ -177,7 +185,7 @@ def _setup_security_headers(self) -> None:
177185
self.config.security.content_security_policy,
178186
)
179187

180-
async def _rate_limit_exception_handler(self, request: Request, exc: RateLimitExceeded) -> JSONResponse:
188+
async def _rate_limit_exception_handler(self, request: Request, exc: RateLimitExceeded) -> CustomJSONResponse:
181189
"""Handle rate limit exceeded exceptions and track metrics.
182190
183191
:param Request request: The incoming HTTP request
@@ -187,7 +195,7 @@ async def _rate_limit_exception_handler(self, request: Request, exc: RateLimitEx
187195
self.rate_limit_exceeded_counter.labels(endpoint=request.url.path).inc()
188196

189197
# Return JSON response with 429 status
190-
return JSONResponse(
198+
return CustomJSONResponse(
191199
status_code=429,
192200
content={"detail": "Rate limit exceeded"},
193201
headers={"Retry-After": str(exc.retry_after)} if hasattr(exc, "retry_after") else {},

tests/conftest.py

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from python_template_server.models import (
1010
CertificateConfigModel,
11+
JSONResponseConfigModel,
1112
RateLimitConfigModel,
1213
SecurityConfigModel,
1314
ServerConfigModel,
@@ -125,28 +126,45 @@ def mock_certificate_config_dict() -> dict:
125126
}
126127

127128

129+
@pytest.fixture
130+
def mock_json_response_config_dict() -> dict:
131+
"""Provide a mock JSON response configuration dictionary."""
132+
return {
133+
"ensure_ascii": False,
134+
"allow_nan": False,
135+
"indent": None,
136+
"media_type": "application/json; charset=utf-8",
137+
}
138+
139+
128140
@pytest.fixture
129141
def mock_server_config(mock_server_config_dict: dict) -> ServerConfigModel:
130142
"""Provide a mock ServerConfigModel instance."""
131-
return ServerConfigModel(**mock_server_config_dict)
143+
return ServerConfigModel.model_validate(mock_server_config_dict)
132144

133145

134146
@pytest.fixture
135147
def mock_security_config(mock_security_config_dict: dict) -> SecurityConfigModel:
136148
"""Provide a mock SecurityConfigModel instance."""
137-
return SecurityConfigModel(**mock_security_config_dict)
149+
return SecurityConfigModel.model_validate(mock_security_config_dict)
138150

139151

140152
@pytest.fixture
141153
def mock_rate_limit_config(mock_rate_limit_config_dict: dict) -> RateLimitConfigModel:
142154
"""Provide a mock RateLimitConfigModel instance."""
143-
return RateLimitConfigModel(**mock_rate_limit_config_dict)
155+
return RateLimitConfigModel.model_validate(mock_rate_limit_config_dict)
144156

145157

146158
@pytest.fixture
147159
def mock_certificate_config(mock_certificate_config_dict: dict) -> CertificateConfigModel:
148160
"""Provide a mock CertificateConfigModel instance."""
149-
return CertificateConfigModel(**mock_certificate_config_dict)
161+
return CertificateConfigModel.model_validate(mock_certificate_config_dict)
162+
163+
164+
@pytest.fixture
165+
def mock_json_response_config(mock_json_response_config_dict: dict) -> JSONResponseConfigModel:
166+
"""Provide a mock JSONResponseConfigModel instance."""
167+
return JSONResponseConfigModel.model_validate(mock_json_response_config_dict)
150168

151169

152170
@pytest.fixture
@@ -155,11 +173,13 @@ def mock_template_server_config(
155173
mock_security_config: SecurityConfigModel,
156174
mock_rate_limit_config: RateLimitConfigModel,
157175
mock_certificate_config: CertificateConfigModel,
176+
mock_json_response_config: JSONResponseConfigModel,
158177
) -> TemplateServerConfig:
159178
"""Provide a mock TemplateServerConfig instance."""
160179
return TemplateServerConfig(
161180
server=mock_server_config,
162181
security=mock_security_config,
163182
rate_limit=mock_rate_limit_config,
164183
certificate=mock_certificate_config,
184+
json_response=mock_json_response_config,
165185
)

tests/test_models.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88
from python_template_server.models import (
99
BaseResponse,
1010
CertificateConfigModel,
11+
CustomJSONResponse,
1112
GetHealthResponse,
13+
JSONResponseConfigModel,
1214
RateLimitConfigModel,
1315
ResponseCode,
1416
SecurityConfigModel,
@@ -87,6 +89,16 @@ def test_days_valid_field(
8789
CertificateConfigModel(**invalid_config_data)
8890

8991

92+
class TestJSONResponseConfigModel:
93+
"""Unit tests for the JSONResponseConfigModel class."""
94+
95+
def test_model_dump(
96+
self, mock_json_response_config_dict: dict, mock_json_response_config: JSONResponseConfigModel
97+
) -> None:
98+
"""Test the model_dump method."""
99+
assert mock_json_response_config.model_dump() == mock_json_response_config_dict
100+
101+
90102
class TestTemplateServerConfig:
91103
"""Unit tests for the TemplateServerConfig class."""
92104

@@ -97,13 +109,15 @@ def test_model_dump(
97109
mock_security_config_dict: dict,
98110
mock_rate_limit_config_dict: dict,
99111
mock_certificate_config_dict: dict,
112+
mock_json_response_config_dict: dict,
100113
) -> None:
101114
"""Test the model_dump method."""
102115
expected_dict = {
103116
"server": mock_server_config_dict,
104117
"security": mock_security_config_dict,
105118
"rate_limit": mock_rate_limit_config_dict,
106119
"certificate": mock_certificate_config_dict,
120+
"json_response": mock_json_response_config_dict,
107121
}
108122
assert mock_template_server_config.model_dump() == expected_dict
109123

@@ -119,6 +133,59 @@ def test_save_to_file(
119133

120134

121135
# API Response Models
136+
class TestCustomJSONResponse:
137+
"""Unit tests for the CustomJSONResponse class."""
138+
139+
def test_configure_method(self, mock_json_response_config: JSONResponseConfigModel) -> None:
140+
"""Test the configure class method."""
141+
CustomJSONResponse.configure(mock_json_response_config)
142+
143+
assert CustomJSONResponse._ensure_ascii == mock_json_response_config.ensure_ascii
144+
assert CustomJSONResponse._allow_nan == mock_json_response_config.allow_nan
145+
assert CustomJSONResponse._indent == mock_json_response_config.indent
146+
assert CustomJSONResponse.media_type == mock_json_response_config.media_type
147+
148+
def test_render_with_unicode(self, mock_json_response_config: JSONResponseConfigModel) -> None:
149+
"""Test rendering JSON with Unicode characters (emojis)."""
150+
CustomJSONResponse.configure(mock_json_response_config)
151+
response = CustomJSONResponse(content={"message": "Hello 👋 World 🌍"})
152+
153+
rendered = response.render({"message": "Hello 👋 World 🌍"})
154+
assert b"Hello \\ud83d\\udc4b World" not in rendered # Should NOT be escaped
155+
assert "👋".encode() in rendered # Should preserve emoji
156+
assert "🌍".encode() in rendered
157+
158+
def test_render_with_ensure_ascii_true(self) -> None:
159+
"""Test rendering with ensure_ascii=True."""
160+
config = JSONResponseConfigModel(ensure_ascii=True)
161+
CustomJSONResponse.configure(config)
162+
response = CustomJSONResponse(content={"message": "Hello 👋"})
163+
164+
rendered = response.render({"message": "Hello 👋"})
165+
# With ensure_ascii=True, Unicode should be escaped
166+
assert b"\\ud83d\\udc4b" in rendered or b"\\u" in rendered
167+
168+
def test_render_with_indent(self) -> None:
169+
"""Test rendering with indentation."""
170+
config = JSONResponseConfigModel(indent=2)
171+
CustomJSONResponse.configure(config)
172+
response = CustomJSONResponse(content={"key": "value"})
173+
174+
rendered = response.render({"key": "value"})
175+
# With indent, output should have newlines and spaces
176+
assert b"\n" in rendered
177+
assert b" " in rendered
178+
179+
def test_render_compact(self, mock_json_response_config: JSONResponseConfigModel) -> None:
180+
"""Test rendering in compact mode (no indent)."""
181+
CustomJSONResponse.configure(mock_json_response_config)
182+
response = CustomJSONResponse(content={"key": "value", "number": 42})
183+
184+
rendered = response.render({"key": "value", "number": 42})
185+
# Compact mode should use "," separator without spaces after
186+
assert rendered == b'{"key":"value","number":42}'
187+
188+
122189
class TestResponseCode:
123190
"""Unit tests for the ResponseCode enum."""
124191

tests/test_template_server.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from python_template_server.middleware import RequestLoggingMiddleware, SecurityHeadersMiddleware
2323
from python_template_server.models import (
2424
BaseResponse,
25+
CustomJSONResponse,
2526
ResponseCode,
2627
ServerHealthStatus,
2728
TemplateServerConfig,
@@ -124,6 +125,24 @@ def test_request_middleware_added(self, mock_template_server: TemplateServer) ->
124125
assert RequestLoggingMiddleware in middlewares
125126
assert SecurityHeadersMiddleware in middlewares
126127

128+
def test_json_response_configured(
129+
self, mock_template_server: TemplateServer, mock_template_server_config: TemplateServerConfig
130+
) -> None:
131+
"""Test that CustomJSONResponse is properly configured during initialization."""
132+
# Verify CustomJSONResponse class variables are set correctly
133+
assert CustomJSONResponse._ensure_ascii == mock_template_server_config.json_response.ensure_ascii
134+
assert CustomJSONResponse._allow_nan == mock_template_server_config.json_response.allow_nan
135+
assert CustomJSONResponse._indent == mock_template_server_config.json_response.indent
136+
assert CustomJSONResponse.media_type == mock_template_server_config.json_response.media_type
137+
138+
# Test that CustomJSONResponse renders correctly with configured settings
139+
response = CustomJSONResponse(content={"test": "data", "emoji": "👋"})
140+
rendered = response.render({"test": "data", "emoji": "👋"})
141+
142+
# With ensure_ascii=False, emojis should be preserved
143+
assert "👋".encode() in rendered
144+
assert b'"test":"data"' in rendered # Compact format (no spaces)
145+
127146

128147
class TestLoadConfig:
129148
"""Tests for the load_config function."""

0 commit comments

Comments
 (0)