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: """ 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)