Files
raynet/app/main.py
T
JiriUhlir e3dccc46d3 3
2026-06-18 15:59:55 +02:00

361 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import logging
import os
from typing import Any, Optional, Type
from fastapi import Body, Depends, FastAPI, Header, HTTPException, Query, Request
from pydantic import BaseModel, ConfigDict, Field, create_model
from app.models import (
BusinessCaseData,
CompanyData,
CreateResponse,
DetailResponse,
EmailData,
EventData,
InvoiceData,
LeadData,
LetterData,
ListResponse,
MeetingData,
OfferData,
PersonData,
PhoneCallData,
PriceListData,
ProductData,
ProjectData,
SalesOrderData,
SimpleResponse,
TaskData,
WebhookData,
)
from app.raynet_client import (
RaynetAuthError,
RaynetClient,
RaynetError,
RaynetValidationError,
)
logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO"))
logger = logging.getLogger(__name__)
APP_NAME = os.getenv("APP_NAME", "raynet")
APP_VERSION = os.getenv("APP_VERSION", "1.0.0")
ROOT_PATH = os.getenv("ROOT_PATH", "")
# Zobrazí se nahoře ve Swaggeru (/docs).
API_DESCRIPTION = """
Stateless proxy nad **RAYNET CRM API v2**.
## Přihlášení (společné pro celé připojení)
Každý endpoint vyžaduje tři hlavičky. Jsou stejné pro všechna volání:
| Hlavička | Co to je | Kde to vzít |
|-----------------------|--------------------|-------------|
| **`X-Api-Key`** | API klíč (secret) | V RAYNET CRM: **Nastavení → Klíč k API**. |
| **`X-Raynet-Email`** | Email uživatele | Přihlašovací email (tvoří s API klíčem Basic Auth). |
| **`X-Instance-Name`** | Název instance | Identifikátor RAYNET instance (subdoména účtu). |
`X-Api-Key` je **secret** nikdy se neloguje ani nevrací z endpointů.
## Endpointy
Typované entity (vidíš strukturu objektu i odpovědi ve Swaggeru) pro každou
platí `POST` (vytvořit), `GET` (seznam), `GET /{id}` (detail), `PUT /{id}`
(úprava), `DELETE /{id}`:
`company`, `person`, `lead`, `businessCase`, `task`, `product`, `offer`,
`salesOrder`, `invoice`.
Generický průchod na **zbytek API** (např. `userAccount`, `project`,
`priceList`, `email`, `file`, číselníky): `/api/{resource}`.
Plná dokumentace RAYNET: <https://app.raynetcrm.com/api/doc/index-en.html>
"""
app = FastAPI(
title=APP_NAME,
version=APP_VERSION,
root_path=ROOT_PATH,
description=API_DESCRIPTION,
)
# --------------------------------------------------------------------------- #
# Sdílená credentials dependency 3 hlavičky pro celé připojení.
# --------------------------------------------------------------------------- #
def get_client(
x_api_key: str = Header(..., alias="X-Api-Key", description="RAYNET API klíč (secret)."),
x_raynet_email: str = Header(..., alias="X-Raynet-Email", description="Email uživatele RAYNET."),
x_instance_name: str = Header(..., alias="X-Instance-Name", description="Název RAYNET instance."),
):
"""Vytvoří RaynetClient z hlaviček a po dokončení requestu uvolní session."""
client = RaynetClient(
api_key=x_api_key,
email=x_raynet_email,
instance_name=x_instance_name,
)
try:
yield client
finally:
client.close()
def _run(fn, *args, **kwargs) -> Any:
"""Spustí volání klienta a převede výjimky konektoru na HTTP odpovědi."""
try:
return fn(*args, **kwargs)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except RaynetAuthError as exc:
raise HTTPException(status_code=401, detail=exc.message) from exc
except RaynetValidationError as exc:
raise HTTPException(status_code=400, detail=exc.message) from exc
except RaynetError as exc:
raise HTTPException(status_code=502, detail=exc.message) from exc
# --------------------------------------------------------------------------- #
# Servisní endpointy
# --------------------------------------------------------------------------- #
@app.get("/health", tags=["service"])
def health():
return {"status": "ok"}
@app.get("/version", tags=["service"])
def version():
return {
"app": APP_NAME,
"version": APP_VERSION,
"language": "python",
"root_path": ROOT_PATH,
}
# --------------------------------------------------------------------------- #
# Generátor typovaných CRUD endpointů pro entitu
# --------------------------------------------------------------------------- #
def _make_patch_model(model: Type[BaseModel]) -> Type[BaseModel]:
"""Z Insert modelu vyrobí model pro částečnou úpravu (vše volitelné)."""
fields = {
fname: (Optional[finfo.annotation], None)
for fname, finfo in model.model_fields.items()
}
return create_model(
f"{model.__name__}Patch",
__config__=ConfigDict(extra="allow"),
**fields,
)
def register_crud(path: str, model: Type[BaseModel], tag: str) -> None:
"""Zaregistruje typované CRUD endpointy pro danou RAYNET entitu."""
patch_model = _make_patch_model(model)
@app.post(
f"/{path}",
response_model=CreateResponse,
tags=[tag],
operation_id=f"{path}_create",
summary=f"Vytvořit ({path})",
)
def _create(payload: model, client: RaynetClient = Depends(get_client)): # type: ignore[valid-type]
data = payload.model_dump(mode="json", exclude_none=True)
res = _run(client.create_record, path, data)
return CreateResponse(success=res.get("success", True), id=res.get("id"), data=res.get("data"))
@app.get(
f"/{path}",
response_model=ListResponse,
tags=[tag],
operation_id=f"{path}_list",
summary=f"Seznam ({path})",
)
def _list(
request: Request,
client: RaynetClient = Depends(get_client),
offset: int = Query(0, ge=0, description="Posun ve výsledcích"),
limit: int = Query(50, ge=1, le=1000, description="Počet záznamů"),
fulltext: Optional[str] = Query(None, description="Fulltextové hledání"),
):
# Předáme všechny query parametry do RAYNET; doplníme výchozí offset/limit.
params = dict(request.query_params)
params.setdefault("offset", offset)
params.setdefault("limit", limit)
res = _run(client.list_records, path, params)
return ListResponse(
success=res.get("success", True),
totalCount=res.get("totalCount"),
data=res.get("data") or [],
)
@app.get(
f"/{path}/{{record_id}}",
response_model=DetailResponse,
tags=[tag],
operation_id=f"{path}_detail",
summary=f"Detail ({path})",
)
def _detail(record_id: int, client: RaynetClient = Depends(get_client)):
res = _run(client.get_record, path, record_id)
return DetailResponse(success=res.get("success", True), data=res.get("data"))
@app.put(
f"/{path}/{{record_id}}",
response_model=SimpleResponse,
tags=[tag],
operation_id=f"{path}_update",
summary=f"Upravit ({path})",
)
def _update(
record_id: int,
payload: patch_model, # type: ignore[valid-type]
client: RaynetClient = Depends(get_client),
):
data = payload.model_dump(mode="json", exclude_none=True)
res = _run(client.update_record, path, record_id, data)
return SimpleResponse(success=res.get("success", True))
@app.delete(
f"/{path}/{{record_id}}",
response_model=SimpleResponse,
tags=[tag],
operation_id=f"{path}_delete",
summary=f"Smazat ({path})",
)
def _delete(record_id: int, client: RaynetClient = Depends(get_client)):
res = _run(client.delete_record, path, record_id)
return SimpleResponse(success=res.get("success", True))
# Registrace typovaných entit (resource path = tag).
ENTITIES: list[tuple[str, Type[BaseModel]]] = [
("company", CompanyData),
("person", PersonData),
("lead", LeadData),
("businessCase", BusinessCaseData),
("offer", OfferData),
("salesOrder", SalesOrderData),
("invoice", InvoiceData),
("product", ProductData),
("priceList", PriceListData),
("project", ProjectData),
("task", TaskData),
("email", EmailData),
("event", EventData),
("meeting", MeetingData),
("phoneCall", PhoneCallData),
("letter", LetterData),
("webhook", WebhookData),
]
for _path, _model in ENTITIES:
register_crud(_path, _model, _path)
# --------------------------------------------------------------------------- #
# Generický průchod na zbytek RAYNET API (netypované entity)
# resource = libovolná entita: userAccount, project, priceList, email, file, ...
# --------------------------------------------------------------------------- #
@app.get("/api/{resource}", tags=["generic"])
def api_list(resource: str, request: Request, client: RaynetClient = Depends(get_client)):
"""Seznam záznamů entity. Query parametry se předávají do RAYNET."""
params = dict(request.query_params)
return _run(client.list_records, resource, params or None)
@app.get("/api/{resource}/{record_id}", tags=["generic"])
def api_detail(resource: str, record_id: str, client: RaynetClient = Depends(get_client)):
"""Detail jednoho záznamu."""
return _run(client.get_record, resource, record_id)
@app.post("/api/{resource}", tags=["generic"])
def api_create(
resource: str,
data: dict = Body(..., description="Data nového záznamu"),
client: RaynetClient = Depends(get_client),
):
"""Vytvoří záznam (mapuje se na RAYNET `PUT /{resource}/`)."""
return _run(client.create_record, resource, data)
@app.put("/api/{resource}/{record_id}", tags=["generic"])
def api_update(
resource: str,
record_id: str,
data: dict = Body(..., description="Pole ke změně"),
client: RaynetClient = Depends(get_client),
):
"""Upraví záznam (mapuje se na RAYNET `POST /{resource}/{id}/`)."""
return _run(client.update_record, resource, record_id, data)
@app.delete("/api/{resource}/{record_id}", tags=["generic"])
def api_delete(resource: str, record_id: str, client: RaynetClient = Depends(get_client)):
"""Smaže záznam."""
return _run(client.delete_record, resource, record_id)
# --------------------------------------------------------------------------- #
# Vnořené zdroje a speciální akce
# Pokrývá např.: /company/{id}/address/, /company/{id}/lock,
# /company/{id}/merge/{srcId}/, /invoice/{id}/cancel, /invoice/{id}/pdfExport,
# /invoice/{id}/payment/, /offer/{id}/item/ atd.
# `sub` je zbytek cesty za /{resource}/{id}/ uveď přesně jak chce RAYNET
# (vč. koncového lomítka u kolekcí, např. "address/" nebo "payment/5/").
# --------------------------------------------------------------------------- #
@app.get("/api/{resource}/{record_id}/{sub:path}", tags=["generic"])
def api_sub_get(
resource: str, record_id: str, sub: str, request: Request,
client: RaynetClient = Depends(get_client),
):
"""GET vnořeného zdroje / akce (např. pdfExport, seznam adres)."""
return _run(client.call, "GET", f"/{resource}/{record_id}/{sub}", dict(request.query_params) or None)
@app.post("/api/{resource}/{record_id}/{sub:path}", tags=["generic"])
def api_sub_post(
resource: str, record_id: str, sub: str,
data: Optional[dict] = Body(None, description="Volitelné tělo (akce nemusí mít žádné)"),
client: RaynetClient = Depends(get_client),
):
"""POST vnořeného zdroje / akce (např. lock, cancel, setPrimary, update)."""
return _run(client.call, "POST", f"/{resource}/{record_id}/{sub}", None, data)
@app.put("/api/{resource}/{record_id}/{sub:path}", tags=["generic"])
def api_sub_put(
resource: str, record_id: str, sub: str,
data: Optional[dict] = Body(None, description="Tělo nového vnořeného záznamu"),
client: RaynetClient = Depends(get_client),
):
"""PUT vnořeného zdroje (create v kolekci, např. address/, payment/, item/)."""
return _run(client.call, "PUT", f"/{resource}/{record_id}/{sub}", None, data)
@app.delete("/api/{resource}/{record_id}/{sub:path}", tags=["generic"])
def api_sub_delete(
resource: str, record_id: str, sub: str,
client: RaynetClient = Depends(get_client),
):
"""DELETE vnořeného zdroje (např. address/5/, payment/3/)."""
return _run(client.call, "DELETE", f"/{resource}/{record_id}/{sub}")
# --------------------------------------------------------------------------- #
# Raw escape-hatch dosáhne na JAKOUKOLI cestu RAYNET API libovolnou metodou.
# Pro speciální případy mimo výše uvedené vzory (např. PUT /invoice/creditNote).
# --------------------------------------------------------------------------- #
class RawRequest(BaseModel):
method: str = Field(..., examples=["GET", "POST", "PUT", "DELETE"])
path: str = Field(..., description="Cesta za base URL, např. /invoice/creditNote", examples=["/company/"])
params: Optional[dict] = Field(default=None, description="Query parametry")
data: Optional[dict] = Field(default=None, description="JSON tělo")
@app.post("/raw", tags=["generic"], summary="Raw volání libovolného RAYNET endpointu")
def raw_call(req: RawRequest, client: RaynetClient = Depends(get_client)):
"""Univerzální volání pokrývá 100 % RAYNET API včetně netypických cest."""
return _run(client.call, req.method, req.path, req.params, req.data)