This commit is contained in:
JiriUhlir
2026-06-18 15:11:49 +02:00
parent 28596fe772
commit a5795fe344
5 changed files with 405 additions and 200 deletions
+174 -33
View File
@@ -1,9 +1,10 @@
import logging
import os
from typing import Any
from fastapi import FastAPI, Header, HTTPException
from fastapi import Body, Depends, FastAPI, Header, HTTPException, Query, Request
from app.models import CreateCompanyRequest, CreateCompanyResponse
from app.models import CompanyData, CreateCompanyResponse
from app.raynet_client import (
RaynetAuthError,
RaynetClient,
@@ -18,20 +19,90 @@ APP_NAME = os.getenv("APP_NAME", "raynet")
APP_VERSION = os.getenv("APP_VERSION", "1.0.0")
ROOT_PATH = os.getenv("ROOT_PATH", "")
# Popis se zobrazí nahoře ve Swaggeru (/docs) přihlašovací údaje jsou
# společné pro celé připojení a posílají se v hlavičkách u KAŽDÉHO requestu.
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** (vygeneruj / zkopíruj). |
| **`X-Raynet-Email`** | Email uživatele | Přihlašovací email do RAYNET (tvoří dvojici s API klíčem pro Basic Auth). |
| **`X-Instance-Name`** | Název instance | Identifikátor tvé RAYNET instance (subdoména účtu). |
`X-Api-Key` je **secret** nikdy se neloguje ani nevrací z endpointů.
## Endpointy
- Typované zkratky pro firmy: `POST/GET/PUT/DELETE /company...`
- Generický průchod na celé API: `/api/{resource}` podporuje libovolnou
RAYNET entitu (`company`, `person`, `lead`, `businessCase`, `activity`,
`product`, `offer`, `order`, `project`, ...).
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="Stateless proxy nad RAYNET CRM API v2.",
description=API_DESCRIPTION,
)
@app.get("/health")
# --------------------------------------------------------------------------- #
# 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")
@app.get("/version", tags=["service"])
def version():
return {
"app": APP_NAME,
@@ -41,35 +112,105 @@ def version():
}
# --------------------------------------------------------------------------- #
# Typované zkratky pro firmy (company)
# --------------------------------------------------------------------------- #
@app.post("/company", response_model=CreateCompanyResponse, tags=["company"])
def create_company(
request: CreateCompanyRequest,
x_api_key: str = Header(
...,
alias="X-Api-Key",
description="RAYNET API klíč (secret). Předává se výhradně hlavičkou.",
),
company: CompanyData,
client: RaynetClient = Depends(get_client),
):
"""Vytvoří firmu v RAYNET CRM.
- **X-Api-Key** (hlavička, secret): API klíč z RAYNET (Nastavení > API).
- **email** / **instance_name** (tělo): identifikátory účtu a instance.
- **company** (tělo): data firmy dle RAYNET CompanyInsertDto.
"""
try:
with RaynetClient(
api_key=x_api_key,
email=request.email,
instance_name=request.instance_name,
) as client:
# Posíláme jen vyplněná pole (žádné None), aby RAYNET nedostal prázdné hodnoty.
data = request.company.model_dump(exclude_none=True)
result = client.create_company(data)
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
"""Vytvoří firmu v RAYNET CRM (validovaná data dle CompanyInsertDto)."""
data = company.model_dump(exclude_none=True)
result = _run(client.create_company, data)
return CreateCompanyResponse(id=result.get("id"), success=True, raw=result)
@app.get("/company", tags=["company"])
def list_companies(
client: RaynetClient = Depends(get_client),
offset: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=1000),
fulltext: str | None = Query(None, description="Fulltextové hledání"),
):
"""Vrátí seznam firem s podporou stránkování a fulltextu."""
params = {"offset": offset, "limit": limit}
if fulltext:
params["fulltext"] = fulltext
return _run(client.list_companies, **params)
@app.get("/company/{company_id}", tags=["company"])
def get_company(company_id: int, client: RaynetClient = Depends(get_client)):
"""Vrátí detail firmy."""
return _run(client.get_company, company_id)
@app.put("/company/{company_id}", tags=["company"])
def update_company(
company_id: int,
data: dict = Body(..., description="Pole firmy ke změně"),
client: RaynetClient = Depends(get_client),
):
"""Upraví firmu."""
return _run(client.update_company, company_id, data)
@app.delete("/company/{company_id}", tags=["company"])
def delete_company(company_id: int, client: RaynetClient = Depends(get_client)):
"""Smaže firmu."""
return _run(client.delete_company, company_id)
# --------------------------------------------------------------------------- #
# Generický průchod na CELÉ RAYNET API
# resource = libovolná entita: company, person, lead, businessCase, activity,
# product, offer, order, project, ...
# --------------------------------------------------------------------------- #
@app.get("/api/{resource}", tags=["generic"])
def api_list(
resource: str,
request: Request,
client: RaynetClient = Depends(get_client),
):
"""Seznam záznamů entity. Všechny query parametry se předávají do RAYNET
(např. `offset`, `limit`, `fulltext`, `name`, ...)."""
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)
-12
View File
@@ -99,18 +99,6 @@ class CompanyData(BaseModel):
model_config = {"extra": "allow"}
class CreateCompanyRequest(BaseModel):
"""Vstup endpointu /company.
API klíč (secret) se předává hlavičkou ``X-Api-Key`` NENÍ součástí tohoto
těla. Email a název instance jsou běžné identifikátory, proto jdou v těle.
"""
email: str = Field(..., examples=["user@firma.cz"], description="Email uživatele RAYNET")
instance_name: str = Field(..., examples=["moje-instance"], description="Název RAYNET instance")
company: CompanyData
class CreateCompanyResponse(BaseModel):
id: Optional[int] = Field(default=None, description="ID nově vytvořené firmy")
success: bool = True
+79 -21
View File
@@ -115,41 +115,99 @@ class RaynetClient:
)
# ------------------------------------------------------------------ #
# Veřejné metody
# Generické CRUD nad libovolnou RAYNET entitou (resource)
#
# RAYNET má napříč celým API jednotné konvence:
# list GET /{resource}/
# detail GET /{resource}/{id}/
# create PUT /{resource}/
# update POST /{resource}/{id}/
# delete DELETE /{resource}/{id}/
# Tyto metody proto pokrývají celé RAYNET API (company, person, lead,
# businessCase, activity, product, offer, order, project, ...).
# ------------------------------------------------------------------ #
def list_records(self, resource: str, params: Optional[dict] = None) -> dict:
"""Vrátí seznam záznamů dané entity.
Args:
resource: název entity, např. ``"company"``, ``"person"``, ``"lead"``.
params: filtry / stránkování (``offset``, ``limit``, ``fulltext``, ...).
"""
return self._request("GET", f"/{self._res(resource)}/", params=params)
def get_record(self, resource: str, record_id: int | str) -> dict:
"""Vrátí detail jednoho záznamu."""
return self._request("GET", f"/{self._res(resource)}/{record_id}/")
def create_record(self, resource: str, data: dict) -> dict:
"""Vytvoří záznam (RAYNET používá pro create metodu PUT)."""
if not isinstance(data, dict):
raise ValueError("Parametr 'data' musí být slovník (dict).")
return self._request("PUT", f"/{self._res(resource)}/", json=data)
def update_record(self, resource: str, record_id: int | str, data: dict) -> dict:
"""Upraví záznam (RAYNET používá pro update metodu POST)."""
if not isinstance(data, dict):
raise ValueError("Parametr 'data' musí být slovník (dict).")
return self._request("POST", f"/{self._res(resource)}/{record_id}/", json=data)
def delete_record(self, resource: str, record_id: int | str) -> dict:
"""Smaže záznam."""
return self._request("DELETE", f"/{self._res(resource)}/{record_id}/")
def call(
self,
method: str,
path: str,
params: Optional[dict] = None,
json: Optional[Any] = None,
) -> dict:
"""Univerzální volání pro vnořené zdroje a speciální akce.
Příklady cest: ``/company/{id}/address/``,
``/company/{id}/lock``, ``/company/{id}/merge/{sourceId}/``.
"""
return self._request(method.upper(), path, params=params, json=json)
# ------------------------------------------------------------------ #
# Pojmenované zkratky pro nejčastější entity (tenké wrappery)
# ------------------------------------------------------------------ #
def create_company(self, data: dict) -> dict:
"""Vytvoří firmu (company) v RAYNET CRM.
RAYNET používá pro vytvoření záznamu HTTP metodu ``PUT`` na kolekci
``/company/`` (nikoli POST).
Args:
data: tělo požadavku dle RAYNET dokumentace. Povinná pole jsou
``name``, ``rating``, ``state`` a ``role``. Příklad kompletní
struktury viz :data:`EXAMPLE_COMPANY` níže.
Returns:
Parsovaná JSON odpověď serveru (obsahuje mj. ``id`` nové firmy).
Raises:
ValueError: pokud ``data`` nejsou slovník nebo chybí povinná pole.
RaynetAuthError: při chybě autentizace (401/403).
RaynetValidationError: při zamítnutí dat serverem (4xx).
RaynetError: při ostatních chybách (síť, 5xx, nevalidní JSON).
Povinná pole: ``name``, ``rating``, ``state``, ``role``.
Příklad struktury viz :data:`EXAMPLE_COMPANY` níže.
"""
if not isinstance(data, dict):
raise ValueError("Parametr 'data' musí být slovník (dict).")
missing = [f for f in ("name", "rating", "state", "role") if not data.get(f)]
missing = [f for f in ("name", "rating", "state", "role") if not (data or {}).get(f)]
if missing:
raise ValueError(
"Firma musí mít vyplněná povinná pole: " + ", ".join(missing) + "."
)
return self.create_record("company", data)
return self._request("PUT", "/company/", json=data)
def list_companies(self, **params: Any) -> dict:
return self.list_records("company", params or None)
def get_company(self, company_id: int | str) -> dict:
return self.get_record("company", company_id)
def update_company(self, company_id: int | str, data: dict) -> dict:
return self.update_record("company", company_id, data)
def delete_company(self, company_id: int | str) -> dict:
return self.delete_record("company", company_id)
# ------------------------------------------------------------------ #
# Interní HTTP vrstva
# ------------------------------------------------------------------ #
@staticmethod
def _res(resource: str) -> str:
"""Očistí název resource (bez lomítek), ať nejde sestavit divná URL."""
cleaned = (resource or "").strip().strip("/")
if not cleaned:
raise ValueError("Název resource nesmí být prázdný.")
return cleaned
def _request(self, method: str, path: str, **kwargs: Any) -> dict:
url = f"{self.base_url}/{path.lstrip('/')}"
kwargs.setdefault("timeout", self.timeout)