mirror of
https://github.com/migatu/astrololo.git
synced 2026-07-14 21:38:37 +00:00
Szkielet aplikacji trójwarstwowej (prezentacja / logika / dane)
Trzy niezależne usługi FastAPI komunikujące się przez HTTP/JSON, każda zna tylko adres warstwy bezpośrednio pod nią: - presentation (:8000) — strona WWW + formularz - logic (:8001) — reguły biznesowe, pośrednik - data (:8002) — wyszukiwanie danych za interfejsem DataProvider Warstwa danych: czytanie setek plików .xlsx z wykrywaniem nagłówka i mapowaniem układu kolumn na schemat kanoniczny, z 4-poziomowym cache (schemat L1, Parquet L2, wyniki zapytań L3, odwrócony indeks L4) i unieważnianiem po odcisku pliku. Gotowa ścieżka migracji do SQL (ingest/to_sql.py + SqlDataProvider, przełączane przez DATA_PROVIDER). Zawiera docker-compose, Makefile, generator danych przykładowych. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8001
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8001"]
|
||||
@@ -0,0 +1,24 @@
|
||||
# Warstwa logiczna (`logic`)
|
||||
|
||||
Niezależna usługa pośrednicząca. **W górę** udostępnia API dla prezentacji,
|
||||
**w dół** woła warstwę bazodanową. Tu żyją reguły biznesowe — nie w prezentacji
|
||||
i nie w bazie.
|
||||
|
||||
## API
|
||||
- `POST /api/query` → `QueryRequest` → `QueryResponse`
|
||||
- `GET /health` (sprawdza też warstwę bazodanową)
|
||||
|
||||
## Zależności w dół
|
||||
Zna wyłącznie `DATA_URL` (adres warstwy bazodanowej) i jej kontrakt `/search`.
|
||||
Nie wie, czy pod spodem jest Excel czy SQL.
|
||||
|
||||
## Uruchomienie
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
export DATA_URL=http://localhost:8002
|
||||
uvicorn app.main:app --port 8001
|
||||
```
|
||||
|
||||
## Gdzie rozbudowywać domenę
|
||||
`service.py` → `QueryService.handle()`: walidacja wejścia, tłumaczenie zapytania,
|
||||
obliczenia i wzbogacanie wyników.
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Klient HTTP do warstwy bazodanowej.
|
||||
|
||||
Jedyny punkt styku w dół. Gdyby warstwa bazodanowa zmieniła implementację
|
||||
(Excel→SQL), tutaj nie zmienia się NIC — kontrakt /search jest stały.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class DataClient:
|
||||
def __init__(self, base_url: str | None = None) -> None:
|
||||
self.base_url = (base_url or settings.data_url).rstrip("/")
|
||||
|
||||
def search(self, key: str, value: str, exact: bool, limit: int) -> dict[str, Any]:
|
||||
payload = {"key": key, "value": value, "exact": exact, "limit": limit}
|
||||
with httpx.Client(timeout=settings.http_timeout) as client:
|
||||
r = client.post(f"{self.base_url}/search", json=payload)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def health(self) -> dict[str, Any]:
|
||||
with httpx.Client(timeout=settings.http_timeout) as client:
|
||||
r = client.get(f"{self.base_url}/health")
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Konfiguracja warstwy logicznej.
|
||||
|
||||
Zna TYLKO adres warstwy bazodanowej (w dół). Nie wie nic o jej wnętrzu
|
||||
(Excel/SQL/cache).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class Settings:
|
||||
data_url: str = field(default_factory=lambda: os.getenv("DATA_URL", "http://localhost:8002"))
|
||||
http_timeout: float = field(default_factory=lambda: float(os.getenv("HTTP_TIMEOUT", "10")))
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Warstwa LOGICZNA — usługa HTTP.
|
||||
|
||||
W górę: udostępnia API dla warstwy prezentacji.
|
||||
W dół: woła warstwę bazodanową (DataClient).
|
||||
Nie serwuje HTML, nie czyta plików/baz — tylko reguły i pośrednictwo.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI, HTTPException
|
||||
|
||||
from app.clients.data_client import DataClient
|
||||
from app.models import QueryRequest, QueryResponse
|
||||
from app.service import QueryService
|
||||
|
||||
app = FastAPI(title="astrololo · warstwa logiczna")
|
||||
service = QueryService()
|
||||
|
||||
|
||||
@app.post("/api/query", response_model=QueryResponse)
|
||||
def query(req: QueryRequest) -> QueryResponse:
|
||||
try:
|
||||
return service.handle(req)
|
||||
except httpx.HTTPError as e:
|
||||
raise HTTPException(status_code=502, detail=f"Warstwa bazodanowa niedostępna: {e}")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict:
|
||||
info = {"status": "ok", "layer": "logic"}
|
||||
try:
|
||||
info["data_layer"] = DataClient().health()
|
||||
except httpx.HTTPError as e:
|
||||
info["data_layer"] = {"status": "down", "error": str(e)}
|
||||
return info
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Kontrakt warstwy logicznej (widziany przez warstwę prezentacji)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class QueryRequest(BaseModel):
|
||||
"""To, co przychodzi z formularza (przez warstwę prezentacji)."""
|
||||
|
||||
query: str = Field(..., min_length=1, description="Szukana fraza.")
|
||||
field: str = Field("name", description="Po którym polu szukać.")
|
||||
exact: bool = False
|
||||
limit: int = Field(25, ge=1, le=200)
|
||||
|
||||
|
||||
class QueryResponse(BaseModel):
|
||||
"""To, co wraca w górę do prezentacji."""
|
||||
|
||||
status: str = "ok"
|
||||
query: str
|
||||
count: int
|
||||
results: list[dict[str, Any]]
|
||||
meta: dict[str, Any] = Field(default_factory=dict)
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Logika biznesowa — serce warstwy logicznej.
|
||||
|
||||
Tu (a nie w prezentacji ani w bazie) żyją reguły: walidacja/normalizacja danych
|
||||
z formularza, tłumaczenie zapytania użytkownika na znormalizowane zapytanie do
|
||||
bazy, oraz opracowanie/wzbogacenie wyników w drodze w górę.
|
||||
|
||||
To jest miejsce do rozbudowy o właściwą domenę (obliczenia, reguły, agregacje).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from app.clients.data_client import DataClient
|
||||
from app.models import QueryRequest, QueryResponse
|
||||
|
||||
|
||||
class QueryService:
|
||||
def __init__(self, data_client: DataClient | None = None) -> None:
|
||||
self.data = data_client or DataClient()
|
||||
|
||||
def handle(self, req: QueryRequest) -> QueryResponse:
|
||||
# 1) normalizacja wejścia z formularza (reguła biznesowa)
|
||||
value = req.query.strip()
|
||||
key = req.field.strip().lower()
|
||||
|
||||
# 2) zapytanie w dół do warstwy bazodanowej
|
||||
raw = self.data.search(key=key, value=value, exact=req.exact, limit=req.limit)
|
||||
|
||||
# 3) opracowanie wyników w górę (tu można liczyć/wzbogacać/sortować)
|
||||
results = raw.get("rows", [])
|
||||
results = sorted(results, key=lambda r: str(r.get(key, "")))
|
||||
|
||||
return QueryResponse(
|
||||
status="ok",
|
||||
query=value,
|
||||
count=len(results),
|
||||
results=results,
|
||||
meta={
|
||||
"field": key,
|
||||
"exact": req.exact,
|
||||
"data_cache": raw.get("cache"),
|
||||
"data_provider": raw.get("provider"),
|
||||
"data_elapsed_ms": raw.get("elapsed_ms"),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,4 @@
|
||||
fastapi>=0.115
|
||||
uvicorn[standard]>=0.34
|
||||
httpx>=0.28
|
||||
pydantic>=2.10
|
||||
Reference in New Issue
Block a user