diff --git a/README.md b/README.md index d54a832..a3d9318 100644 --- a/README.md +++ b/README.md @@ -92,8 +92,11 @@ uv run generate-new-token uv run python-template-server # Server runs at https://localhost:443/api -# Health check: curl -k https://localhost:443/api/health +# Swagger UI: https://localhost:443/api/docs +# Redoc: https://localhost:443/api/redoc # Metrics: curl -k https://localhost:443/api/metrics +# Health check: curl -k https://localhost:443/api/health +# Login (requires authentication): curl -k -H "X-API-Key: your-token-here" https://localhost:443/api/login ``` ## Using as a Template diff --git a/configuration/config.json b/configuration/config.json index 6003d43..6020a8a 100644 --- a/configuration/config.json +++ b/configuration/config.json @@ -5,7 +5,7 @@ }, "security": { "hsts_max_age": 31536000, - "content_security_policy": "default-src 'self'" + "content_security_policy": "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; img-src 'self' data: https://cdn.jsdelivr.net https://fastapi.tiangolo.com" }, "rate_limit": { "enabled": true, diff --git a/docs/API.md b/docs/API.md index 87c36c1..536592b 100644 --- a/docs/API.md +++ b/docs/API.md @@ -22,7 +22,11 @@ All endpoints are mounted under the `/api` prefix. - [Grafana Dashboards](#grafana-dashboards) - [Endpoints](#endpoints) - [GET /api/health](#get-apihealth) + - [GET /api/login](#get-apilogin) - [Request and Response Models (Pydantic)](#request-and-response-models-pydantic) +- [API Documentation](#api-documentation) + - [Swagger UI (/api/docs)](#swagger-ui-apidocs) + - [ReDoc (/api/redoc)](#redoc-apiredoc) ## Authentication @@ -211,13 +215,78 @@ curl -k https://localhost:443/api/health } ``` +### GET /api/login + +**Purpose**: Verify API token and return successful login message. + +**Authentication**: Required (API key must be provided) + +**Rate Limiting**: Subject to rate limits (default: 100/minute) + +**Request**: None + +**Response Model**: `GetLoginResponse` +- `code` (int): HTTP status code +- `message` (string): Login status message +- `timestamp` (string): ISO 8601 timestamp + +**Example Request**: +```bash +curl -k https://localhost:443/api/login \ + -H "X-API-Key: your-api-token-here" +``` + +**Example Response** (200 OK): +```json +{ + "code": 200, + "message": "Login successful.", + "timestamp": "2025-11-22T12:00:00.000000Z" +} +``` + +**Error Responses**: +- `401 Unauthorized`: Missing or invalid API key +- `429 Too Many Requests`: Rate limit exceeded + ## Request and Response Models (Pydantic) The primary Pydantic models are defined in `python_template_server/models.py`: - `BaseResponse`: Base model with code, message, and timestamp fields - `GetHealthResponse`: Extends BaseResponse with status field (HEALTHY/UNHEALTHY) +- `GetLoginResponse`: Extends BaseResponse for login endpoint responses - `TemplateServerConfig`: Configuration model for server settings (security, rate limiting, JSON response) **Extending Configurations**: Extend the `TemplateServerConfig` class to get the necessary server setup configuration. **Extending Models**: When building your own server, create custom response models by extending `BaseResponse` for consistent API responses. + +## API Documentation + +FastAPI automatically generates interactive API documentation, providing two different interfaces for exploring and testing the API. + +### Swagger UI (/api/docs) + +**URL**: `https://localhost:443/api/docs` + +**Purpose**: Interactive API documentation with "Try it out" functionality + +**Features**: +- Execute API calls directly from the browser +- View request/response schemas +- Test authentication with API keys +- Explore all available endpoints +- View models and their properties + +### ReDoc (/api/redoc) + +**URL**: `https://localhost:443/api/redoc` + +**Purpose**: Alternative API documentation with a clean, three-panel layout + +**Features**: +- Read-only documentation interface +- Clean, responsive design +- Search functionality +- Detailed schema information +- Markdown support in descriptions diff --git a/docs/DOCKER_DEPLOYMENT.md b/docs/DOCKER_DEPLOYMENT.md index 69cd015..12fb3e7 100644 --- a/docs/DOCKER_DEPLOYMENT.md +++ b/docs/DOCKER_DEPLOYMENT.md @@ -174,8 +174,9 @@ docker compose down -v **Base URL**: `https://localhost:443` **API Endpoints**: -- Health Check: `GET /api/health` (publicly accessible, no authentication required) - Metrics: `GET /api/metrics` (publicly accessible, no authentication required) +- Health Check: `GET /api/health` (publicly accessible, no authentication required) +- Login: `GET /api/login` (requires authentication with X-API-Key header) - Custom Endpoints: Defined in your server subclass (authentication may be required) **Example Request**: @@ -183,8 +184,8 @@ docker compose down -v # Using curl (with self-signed cert) curl -k https://localhost:443/api/health -# Authenticated request to custom endpoint -curl -k -H "X-API-Key: your-token-here" https://localhost:443/api/your-endpoint +# Login endpoint (authenticated) +curl -k -H "X-API-Key: your-token-here" https://localhost:443/api/login ``` ### Prometheus diff --git a/docs/SMG.md b/docs/SMG.md index 93b6d54..7ad2e65 100644 --- a/docs/SMG.md +++ b/docs/SMG.md @@ -127,19 +127,20 @@ uv run python-template-server The backend will be available at `https://localhost:443/api` by default. **Available Endpoints:** -- Health Check: `https://localhost:443/api/health` - Prometheus Metrics: `https://localhost:443/api/metrics` +- Health Check: `https://localhost:443/api/health` +- Login: `https://localhost:443/api/login` (requires authentication) **Testing the API:** ```sh -# Health check (no auth required) -curl -k https://localhost:443/api/health - # Metrics endpoint (no auth required) curl -k https://localhost:443/api/metrics -# Add custom authenticated endpoints in your server subclass -curl -k -H "X-API-Key: your-token-here" https://localhost:443/api/your-endpoint +# Health check (no auth required) +curl -k https://localhost:443/api/health + +# Login endpoint (requires authentication) +curl -k -H "X-API-Key: your-token-here" https://localhost:443/api/login ``` ### Testing, Linting, and Type Checking diff --git a/python_template_server/models.py b/python_template_server/models.py index 398f542..26ee43d 100644 --- a/python_template_server/models.py +++ b/python_template_server/models.py @@ -198,3 +198,7 @@ class GetHealthResponse(BaseResponse): """Response model for the health endpoint.""" status: ServerHealthStatus = Field(..., description="Health status of the server") + + +class GetLoginResponse(BaseResponse): + """Response model for login endpoint.""" diff --git a/python_template_server/template_server.py b/python_template_server/template_server.py index 08f924c..9ebbe95 100644 --- a/python_template_server/template_server.py +++ b/python_template_server/template_server.py @@ -26,6 +26,7 @@ from python_template_server.models import ( CustomJSONResponse, GetHealthResponse, + GetLoginResponse, ResponseCode, ServerHealthStatus, TemplateServerConfig, @@ -328,6 +329,7 @@ def setup_routes(self) -> None: """ self.add_unauthenticated_route("/health", self.get_health, GetHealthResponse, ["GET"], limited=False) + self.add_authenticated_route("/login", self.get_login, GetLoginResponse, methods=["GET"]) async def get_health(self, request: Request) -> GetHealthResponse: """Get server health. @@ -351,3 +353,12 @@ async def get_health(self, request: Request) -> GetHealthResponse: timestamp=GetHealthResponse.current_timestamp(), status=ServerHealthStatus.HEALTHY, ) + + async def get_login(self, request: Request) -> GetLoginResponse: + """Handle user login and return a success response.""" + logger.info("User login successful.") + return GetLoginResponse( + code=ResponseCode.OK, + message="Login successful.", + timestamp=GetLoginResponse.current_timestamp(), + ) diff --git a/tests/test_models.py b/tests/test_models.py index aec19aa..4cd15a3 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -12,6 +12,7 @@ CertificateConfigModel, CustomJSONResponse, GetHealthResponse, + GetLoginResponse, JSONResponseConfigModel, MetricConfig, MetricTypes, @@ -291,7 +292,8 @@ class TestBaseResponse: def test_model_dump(self) -> None: """Test the model_dump method.""" - config_dict: dict = {"code": ResponseCode.OK, "message": "Success", "timestamp": "2025-11-22T12:00:00Z"} + timestamp = BaseResponse.current_timestamp() + config_dict: dict = {"code": ResponseCode.OK, "message": "Success", "timestamp": timestamp} response = BaseResponse(**config_dict) assert response.model_dump() == config_dict @@ -301,11 +303,27 @@ class TestGetHealthResponse: def test_model_dump(self) -> None: """Test the model_dump method.""" + timestamp = GetHealthResponse.current_timestamp() config_dict: dict = { "code": ResponseCode.OK, "message": "Server is healthy", - "timestamp": "2025-11-22T12:00:00Z", + "timestamp": timestamp, "status": ServerHealthStatus.HEALTHY, } response = GetHealthResponse(**config_dict) assert response.model_dump() == config_dict + + +class TestGetLoginResponse: + """Unit tests for the GetLoginResponse class.""" + + def test_model_dump(self) -> None: + """Test the model_dump method.""" + timestamp = GetLoginResponse.current_timestamp() + config_dict: dict = { + "code": ResponseCode.OK, + "message": "Login successful", + "timestamp": timestamp, + } + response = GetLoginResponse(**config_dict) + assert response.model_dump() == config_dict diff --git a/tests/test_template_server.py b/tests/test_template_server.py index 96d644d..b02a2b4 100644 --- a/tests/test_template_server.py +++ b/tests/test_template_server.py @@ -563,6 +563,7 @@ def test_setup_routes(self, mock_template_server: MockTemplateServer) -> None: routes = [route.path for route in api_routes] expected_endpoints = [ "/health", + "/login", "/metrics", "/unauthenticated-endpoint", "/authenticated-endpoint", @@ -573,7 +574,7 @@ def test_setup_routes(self, mock_template_server: MockTemplateServer) -> None: assert endpoint in routes -class TestHealthEndpoint: +class TestGetHealthEndpoint: """Integration tests for the /health endpoint.""" def test_get_health(self, mock_template_server: TemplateServer) -> None: @@ -602,7 +603,7 @@ def test_get_health_token_not_configured(self, mock_template_server: TemplateSer assert token_gauge is not None assert token_gauge._value.get() == 0 - def test_health_endpoint( + def test_get_health_endpoint( self, mock_template_server: TemplateServer, mock_verify_token: MagicMock, mock_timestamp: str ) -> None: """Test /health endpoint returns 200.""" @@ -618,3 +619,31 @@ def test_health_endpoint( "timestamp": mock_timestamp, "status": ServerHealthStatus.HEALTHY, } + + +class TestGetLoginEndpoint: + """Integration tests for the /login endpoint.""" + + def test_get_login(self, mock_template_server: TemplateServer) -> None: + """Test the /login endpoint method.""" + request = MagicMock() + response = asyncio.run(mock_template_server.get_login(request)) + + assert response.code == ResponseCode.OK + assert response.message == "Login successful." + + def test_get_login_endpoint( + self, mock_template_server: TemplateServer, mock_verify_token: MagicMock, mock_timestamp: str + ) -> None: + """Test /login endpoint returns 200.""" + mock_verify_token.return_value = True + app = mock_template_server.app + client = TestClient(app) + + response = client.get("/login", headers={"X-API-Key": "test-token"}) + assert response.status_code == ResponseCode.OK + assert response.json() == { + "code": ResponseCode.OK, + "message": "Login successful.", + "timestamp": mock_timestamp, + }