361 lines
13 KiB
Python
361 lines
13 KiB
Python
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)
|