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
6 changes: 6 additions & 0 deletions configuration/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
39 changes: 39 additions & 0 deletions python_template_server/models.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -61,13 +64,23 @@ 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."""

server: ServerConfigModel = Field(default_factory=ServerConfigModel)
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.
Expand All @@ -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."""

Expand Down
16 changes: 12 additions & 4 deletions python_template_server/template_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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__)
Expand Down Expand Up @@ -63,13 +68,16 @@ 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"],
description=self.package_metadata["Summary"],
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)

Expand Down Expand Up @@ -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
Expand All @@ -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 {},
Expand Down
28 changes: 24 additions & 4 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from python_template_server.models import (
CertificateConfigModel,
JSONResponseConfigModel,
RateLimitConfigModel,
SecurityConfigModel,
ServerConfigModel,
Expand Down Expand Up @@ -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
Expand All @@ -155,11 +173,13 @@ 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(
server=mock_server_config,
security=mock_security_config,
rate_limit=mock_rate_limit_config,
certificate=mock_certificate_config,
json_response=mock_json_response_config,
)
67 changes: 67 additions & 0 deletions tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
from python_template_server.models import (
BaseResponse,
CertificateConfigModel,
CustomJSONResponse,
GetHealthResponse,
JSONResponseConfigModel,
RateLimitConfigModel,
ResponseCode,
SecurityConfigModel,
Expand Down Expand Up @@ -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."""

Expand All @@ -97,13 +109,15 @@ 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 = {
"server": mock_server_config_dict,
"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

Expand All @@ -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."""

Expand Down
19 changes: 19 additions & 0 deletions tests/test_template_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from python_template_server.middleware import RequestLoggingMiddleware, SecurityHeadersMiddleware
from python_template_server.models import (
BaseResponse,
CustomJSONResponse,
ResponseCode,
ServerHealthStatus,
TemplateServerConfig,
Expand Down Expand Up @@ -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."""
Expand Down