Skip to content
26 changes: 19 additions & 7 deletions python_template_server/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,36 @@
from pathlib import Path
from typing import Any

from python_template_server.constants import CONFIG_FILE_PATH
from python_template_server.constants import CONFIG_FILE_PATH, STATIC_DIR
from python_template_server.models import TemplateServerConfig
from python_template_server.routers import BaseRouter
from python_template_server.template_server import TemplateServer


class ExampleServer(TemplateServer):
"""Example server inheriting from TemplateServer."""

def __init__(self, config_filepath: Path = CONFIG_FILE_PATH) -> None:
def __init__(
self,
config_filepath: Path = CONFIG_FILE_PATH,
config: TemplateServerConfig | None = None,
static_dir: Path = STATIC_DIR,
) -> None:
"""Initialize the ExampleServer by delegating to the template server.

:param TemplateServerConfig config: Configuration object
:param Path config_filepath: Configuration filepath
:param Path static_dir: Static files directory
"""
super().__init__(config_filepath=config_filepath)
super().__init__(config=config, config_filepath=config_filepath, static_dir=static_dir)

@property
def routers(self) -> list[BaseRouter]:
"""Define the API routers for the server.

:return list[BaseRouter]: List of API routers
"""
return []

def validate_config(self, config_data: dict[str, Any]) -> TemplateServerConfig:
"""Validate configuration from the config.json file.
Expand All @@ -25,10 +41,6 @@ def validate_config(self, config_data: dict[str, Any]) -> TemplateServerConfig:
"""
return super().validate_config(config_data)

def setup_routes(self) -> None:
"""Set up API routes."""
pass


def run() -> None:
"""Serve the FastAPI application using uvicorn.
Expand Down
6 changes: 6 additions & 0 deletions python_template_server/routers/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Routers for the FastAPI server."""

from .base_router import BaseRouter
from .template_server_router import TemplateServerRouter

__all__ = ["BaseRouter", "TemplateServerRouter"]
109 changes: 109 additions & 0 deletions python_template_server/routers/base_router.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
"""Base router for the FastAPI server."""

import logging
from abc import ABC, abstractmethod
from collections.abc import Callable

from fastapi import APIRouter, HTTPException, Security
from fastapi.security import APIKeyHeader
from pydantic import BaseModel
from slowapi import Limiter

from python_template_server.authentication_handler import verify_token
from python_template_server.constants import API_KEY_HEADER_NAME
from python_template_server.models import ResponseCode

logger = logging.getLogger(__name__)


API_KEY_HEADER = APIKeyHeader(name=API_KEY_HEADER_NAME, auto_error=False)


class BaseRouter(ABC):
"""Abstract base class for API routers."""

def __init__(self, prefix: str) -> None:
"""Initialize the base router."""
self.router = APIRouter(prefix=prefix)

self.hashed_token: str = ""
self.limiter: Limiter | None
self.rate_limit: str

@abstractmethod
def setup_routes(self) -> None:
"""Abstract method to set up API routes."""
pass

async def _verify_api_key(self, api_key: str | None = Security(API_KEY_HEADER)) -> None:
"""Verify the API key from the request header.

:param str | None api_key: The API key from the X-API-Key header
:raise HTTPException: If the API key is missing or invalid
"""
if api_key is None:
logger.warning("Missing API key in request!")
raise HTTPException(
status_code=ResponseCode.BAD_REQUEST,
detail="Missing API key",
)

try:
if not verify_token(api_key, self.hashed_token):
logger.warning("Invalid API key attempt!")
raise HTTPException(
status_code=ResponseCode.UNAUTHORIZED,
detail="Invalid API key",
)
except ValueError as e:
logger.exception("Error verifying API key!")
raise HTTPException(
status_code=ResponseCode.INTERNAL_SERVER_ERROR,
detail=str(e),
) from e

def configure(self, hashed_token: str, limiter: Limiter | None, rate_limit: str) -> None:
"""Configure the router with shared dependencies.

:param str hashed_token: The hashed token for API key verification
:param Limiter | None limiter: The rate limiter instance to use for this router
:param str rate_limit: The rate limit string to apply to limited routes
"""
self.hashed_token = hashed_token
self.limiter = limiter
self.rate_limit = rate_limit

def add_route(
self,
endpoint: str,
handler_function: Callable,
response_model: type[BaseModel],
methods: list[str],
limited: bool, # noqa: FBT001
authentication_required: bool, # noqa: FBT001
) -> None:
"""Add an API route.

:param str endpoint: The API endpoint path
:param Callable handler_function: The handler function for the endpoint
:param BaseModel response_model: The Pydantic model for the response
:param list[str] methods: The HTTP methods for the endpoint
:param bool limited: Whether to apply rate limiting to this route
:param bool authentication_required: Whether authentication is required for this route
"""
try:
limited_method = None
if limited and self.limiter is not None:
limited_method = self.limiter.limit(self.rate_limit)(handler_function)

self.router.add_api_route(
path=endpoint,
endpoint=limited_method or handler_function,
methods=methods,
response_model=response_model,
dependencies=[Security(self._verify_api_key)] if authentication_required else None,
)
except AttributeError as e:
error_msg = "Router not configured with limiter and rate limit. Call configure() before adding routes."
logger.exception(error_msg)
raise RuntimeError(error_msg) from e
47 changes: 47 additions & 0 deletions python_template_server/routers/template_server_router.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""Template server router with health and login endpoints."""

from fastapi import Request

from python_template_server.models import GetHealthResponse, GetLoginResponse
from python_template_server.routers import BaseRouter


class TemplateServerRouter(BaseRouter):
"""Router for the template server with health and login endpoints."""

def setup_routes(self) -> None:
"""Set up the API routes for the template server."""
self.add_route(
endpoint="/health",
handler_function=self.get_health,
response_model=GetHealthResponse,
methods=["GET"],
limited=False,
authentication_required=False,
)
self.add_route(
endpoint="/login",
handler_function=self.get_login,
response_model=GetLoginResponse,
methods=["GET"],
limited=True,
authentication_required=True,
)

async def get_health(self, request: Request) -> GetHealthResponse:
"""Get server health.

:param Request request: The incoming HTTP request
:return GetHealthResponse: Health status response
:raise HTTPException: If the server token is not configured
"""
return GetHealthResponse(message="Server is healthy")

async def get_login(self, request: Request) -> GetLoginResponse:
"""Handle user login and return a success response.

:param Request request: The incoming HTTP request
:return GetLoginResponse: Login success response
:raise HTTPException: If the server token is not configured
"""
return GetLoginResponse(message="Login successful.")
Loading