feat(mcp-docugen): Task 1-3 config, models, auth middleware

- Settings con Pydantic Settings, validazione env obbligatori
- Shared models: TemplateVariable/Frontmatter, ImageVariable, TokenUsage, GenerationResult, TemplateSummary, TemplateAsset
- ApiKeyAuthMiddleware Bearer token con exempt paths

19 test, tutti passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-21 12:18:43 +02:00
parent c5e84a578b
commit d5c645bf17
6 changed files with 324 additions and 0 deletions
@@ -0,0 +1,34 @@
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.types import ASGIApp
class ApiKeyAuthMiddleware(BaseHTTPMiddleware):
def __init__(
self,
app: ASGIApp,
api_key: str,
exempt_paths: set[str] | None = None,
) -> None:
super().__init__(app)
self.api_key = api_key
self.exempt_paths = exempt_paths or set()
async def dispatch(self, request: Request, call_next):
if request.url.path in self.exempt_paths:
return await call_next(request)
header = request.headers.get("Authorization", "")
if not header.startswith("Bearer "):
return JSONResponse(
status_code=401, content={"error": "invalid_api_key"}
)
provided = header.removeprefix("Bearer ").strip()
if provided != self.api_key:
return JSONResponse(
status_code=401, content={"error": "invalid_api_key"}
)
return await call_next(request)
@@ -0,0 +1,25 @@
from pathlib import Path
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env", env_file_encoding="utf-8", extra="ignore"
)
api_key: str = Field(..., min_length=8)
openrouter_api_key: str = Field(..., min_length=8)
openrouter_base_url: str = "https://openrouter.ai/api/v1"
llm_model_default: str = "anthropic/claude-sonnet-4"
public_base_url: str = Field(...)
data_dir: Path = Path("/data")
asset_ttl_days: int = 30
max_image_size_mb: int = 10
llm_timeout_seconds: int = 60
@field_validator("public_base_url")
@classmethod
def strip_trailing_slash(cls, v: str) -> str:
return v.rstrip("/")
@@ -0,0 +1,72 @@
import re
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field, field_validator
_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
_SUPPORTED_MIMES = {"image/png", "image/jpeg", "image/webp"}
class TemplateVariable(BaseModel):
name: str = Field(..., min_length=1)
type: Literal["string", "image"]
class TemplateFrontmatter(BaseModel):
name: str
description: str = ""
model: str | None = None
required_variables: list[TemplateVariable] = Field(default_factory=list)
instructions_hint: str | None = None
@field_validator("name")
@classmethod
def validate_slug(cls, v: str) -> str:
if not _SLUG_RE.match(v):
raise ValueError(
f"invalid template name '{v}': must match {_SLUG_RE.pattern}"
)
return v
class ImageVariable(BaseModel):
kind: Literal["image"]
data_b64: str
mime: str
@field_validator("mime")
@classmethod
def validate_mime(cls, v: str) -> str:
if v not in _SUPPORTED_MIMES:
raise ValueError(
f"unsupported mime '{v}'; allowed: {sorted(_SUPPORTED_MIMES)}"
)
return v
class TokenUsage(BaseModel):
input: int = 0
output: int = 0
class GenerationResult(BaseModel):
generation_id: str
markdown: str
model: str
tokens: TokenUsage
cost_usd: float
ephemeral_assets_urls: list[str] = Field(default_factory=list)
ephemeral_expires_at: datetime | None = None
class TemplateSummary(BaseModel):
name: str
description: str
updated_at: datetime
class TemplateAsset(BaseModel):
filename: str
mime: str
size_bytes: int