diff --git a/README.md b/README.md index eeae16c..c5f58b5 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,50 @@ # PPL CPL API +Stateless multi-tenant proxy nad **PPL CPL API** (Create Package Label) běžící +v AppFactory. Umožňuje tvorbu zásilek a tisk etiket, tracking, objednávky +svozu/přepravy, číselníky, výdejní místa a našeptávač adres. + +## Přihlašovací údaje + +Předávají se **per-request v hlavičkách** (nikdy v těle, URL ani konfiguraci): + +| Hlavička | Význam | +|---|---| +| `X-Client-Id` | PPL CPL ClientId (přiděluje PPL) | +| `X-Client-Secret` | PPL CPL ClientSecret | +| `X-Environment` | volitelně `production` (default) / `test` | + +Služba si sama vyžádá OAuth Bearer token (client_credentials, scope `myapi2`) +a cachuje ho in-memory podle hashe údajů — PPL limituje vydávání tokenů na +12/min a token platí 30 minut. + +## Hlavní endpointy + +- `POST /shipments/batch` → vytvoření zásilek, vrací `batchId` +- `GET /shipments/batch/{batchId}` → stav importu (Accepted/InProcess/Complete/Error) +- `GET /shipments/batch/{batchId}/labels` → binární etikety (PDF/ZPL/JPG…) +- `POST /shipments/create-and-wait` → celý tok v jednom requestu (volitelně etikety v base64) +- `GET /shipments` → tracking +- `POST /shipments/{n}/cancel`, `POST /shipments/{n}/redirect`, `POST /shipments/{n}/documents` +- `POST /orders/batch`, `GET /orders/batch/{batchId}`, `POST /orders/create-and-wait`, `GET /orders`, `POST /orders/cancel` +- `GET /codelists/{name}`, `GET /access-points`, `GET /address-whisper`, `GET /routing` +- `GET /customer`, `GET /customer/addresses`, `POST /customer/number-range` +- `/proxy/{cesta}` → generické volání libovolné metody CPL API 1:1 + +Kompletní popis viz [documentation/](documentation/) a Swagger na `/docs`. + +## Poznámky + +- CPL API přijímá jen Latin znaky bez diakritiky — texty v create requestech se + defaultně transliterují (`transliterate=false` to vypne). +- Služba dodržuje minimální rozestup requestů na PPL (40 ms). +- Chyby: JSON `{error, message, detail}`, `detail` nese problem+json z PPL. + +## Lokální spuštění + +```bash +pip install -r requirements.txt +uvicorn app.main:app --host 0.0.0.0 --port 8000 +``` + Generated by AppFactory. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..32dfcd6 --- /dev/null +++ b/app/config.py @@ -0,0 +1,45 @@ +"""Konfigurace čtená z environment variables (AppFactory runtime .env). + +Žádné secrets zde nejsou — přihlašovací údaje (ClientId/ClientSecret) se předávají +per-request přes X- hlavičky (viz app/credentials.py). Zde jsou pouze veřejné +defaulty, base URL adresy PPL CPL API a nastavení proxy. +""" +import os + +APP_NAME = os.getenv("APP_NAME", "PPL CPL API") +APP_VERSION = os.getenv("APP_VERSION", "1.0.0") +ROOT_PATH = os.getenv("ROOT_PATH", "") + +# Base URL adresy CPL API (přepsatelné přes env, kdyby se změnily). +CPL_PRODUCTION_BASE_URL = os.getenv( + "CPL_PRODUCTION_BASE_URL", "https://api.dhl.com/ecs/ppl/myapi2" +) +CPL_TEST_BASE_URL = os.getenv( + "CPL_TEST_BASE_URL", "https://api-dev.dhl.com/ecs/ppl/myapi2" +) + +# Které prostředí se použije, když request nepošle hlavičku X-Environment. +CPL_DEFAULT_ENVIRONMENT = os.getenv("CPL_DEFAULT_ENVIRONMENT", "production") + +# OAuth2 client_credentials scope dle dokumentace CPL. +CPL_OAUTH_SCOPE = os.getenv("CPL_OAUTH_SCOPE", "myapi2") + +# Timeout pro upstream volání. +UPSTREAM_TIMEOUT_SECONDS = float(os.getenv("UPSTREAM_TIMEOUT_SECONDS", "60")) + +# Token platí 30 minut; obnovujeme ho s předstihem, ať nikdy nepošleme prošlý. +TOKEN_REFRESH_MARGIN_SECONDS = float(os.getenv("TOKEN_REFRESH_MARGIN_SECONDS", "60")) + +# PPL vyžaduje min. 40 ms rozestup mezi po sobě jdoucími requesty. +MIN_REQUEST_INTERVAL_SECONDS = float(os.getenv("MIN_REQUEST_INTERVAL_SECONDS", "0.04")) + +# Polling stavu batch importu u convenience endpointů create-and-wait. +BATCH_POLL_INTERVAL_SECONDS = float(os.getenv("BATCH_POLL_INTERVAL_SECONDS", "1.0")) +BATCH_WAIT_TIMEOUT_SECONDS = float(os.getenv("BATCH_WAIT_TIMEOUT_SECONDS", "30")) + +# CPL přijímá pouze Latin znaky bez diakritiky — defaultně texty transliterujeme. +TRANSLITERATE_DEFAULT = os.getenv("CPL_TRANSLITERATE", "true").strip().lower() in ( + "1", + "true", + "yes", +) diff --git a/app/cpl_client.py b/app/cpl_client.py new file mode 100644 index 0000000..1918473 --- /dev/null +++ b/app/cpl_client.py @@ -0,0 +1,240 @@ +"""HTTP klient pro PPL CPL API. + +Zajišťuje: +- získání a cachování OAuth Bearer tokenu (client_credentials, scope myapi2), +- minimální rozestup mezi requesty (PPL vyžaduje >= 40 ms), +- jeden retry s čerstvým tokenem, pokud PPL vrátí 401 (token mohl být revokován), +- pomocné funkce pro předání odpovědi PPL klientovi (JSON / binární etikety). + +Secrets se nikdy nelogují — loguje se pouze metoda, cesta a status. +""" +import asyncio +import time +from typing import Any + +import httpx +from fastapi.responses import JSONResponse, Response + +from . import token_cache +from .config import ( + CPL_OAUTH_SCOPE, + MIN_REQUEST_INTERVAL_SECONDS, + UPSTREAM_TIMEOUT_SECONDS, +) +from .credentials import Credentials +from .errors import CredentialsError, UpstreamError, raise_for_upstream +from .logging_config import get_logger + +log = get_logger("pplcpl.client") + +TOKEN_PATH = "/login/getAccessToken" + +# Hlavičky PPL, které má smysl předat klientovi (paging, korelace, Location). +_RELAY_HEADERS = ( + "location", + "x-correlation-id", + "x-paging-total-items-count", + "x-paging-offset", + "x-paging-limit", + "content-disposition", +) + +_client: httpx.AsyncClient | None = None + +_throttle_lock = asyncio.Lock() +_last_request_at = 0.0 + + +def _http() -> httpx.AsyncClient: + global _client + if _client is None: + _client = httpx.AsyncClient(timeout=UPSTREAM_TIMEOUT_SECONDS) + return _client + + +async def close_client() -> None: + global _client + if _client is not None: + await _client.aclose() + _client = None + + +async def _throttle() -> None: + """PPL vyžaduje minimálně 40 ms rozestup mezi po sobě jdoucími requesty.""" + global _last_request_at + async with _throttle_lock: + now = time.monotonic() + wait = MIN_REQUEST_INTERVAL_SECONDS - (now - _last_request_at) + if wait > 0: + await asyncio.sleep(wait) + _last_request_at = time.monotonic() + + +async def _fetch_token(creds: Credentials) -> str: + await _throttle() + try: + resp = await _http().post( + creds.base_url + TOKEN_PATH, + data={ + "grant_type": "client_credentials", + "client_id": creds.client_id.strip(), + "client_secret": creds.client_secret.strip(), + "scope": CPL_OAUTH_SCOPE, + }, + ) + except httpx.HTTPError as exc: + log.error("Token endpoint PPL nedostupný (%s): %s", creds.environment, exc.__class__.__name__) + raise UpstreamError("PPL CPL API (token endpoint) je nedostupné.") from exc + + if resp.status_code != 200: + log.warning( + "PPL odmítlo vydání tokenu (%s): HTTP %s", creds.environment, resp.status_code + ) + raise CredentialsError( + f"PPL odmítlo přihlašovací údaje při vydávání tokenu (HTTP {resp.status_code}).", + detail=(resp.text or "")[:500], + ) + + payload = resp.json() + token = payload.get("access_token") + if not token: + log.error("Token endpoint PPL vrátil 200 bez access_token.") + raise UpstreamError("PPL vrátilo neplatnou odpověď z token endpointu.") + + expires_in = float(payload.get("expires_in") or 1800) + token_cache.store(creds.cache_key, token, expires_in) + log.info("Vydán nový PPL token (%s), platnost %ss.", creds.environment, int(expires_in)) + return token + + +async def get_access_token(creds: Credentials, force_refresh: bool = False) -> str: + creds.require() + if not force_refresh: + cached = token_cache.get_cached(creds.cache_key) + if cached: + return cached + lock = await token_cache.acquire_lock(creds.cache_key) + async with lock: + if not force_refresh: + cached = token_cache.get_cached(creds.cache_key) + if cached: + return cached + return await _fetch_token(creds) + + +async def cpl_request( + creds: Credentials, + method: str, + path: str, + *, + params: Any = None, + json_body: Any = None, + content: bytes | None = None, + content_type: str | None = None, + files: Any = None, +) -> httpx.Response: + """Provede autentizovaný request na PPL CPL API a vrátí surovou odpověď. + + Na 401 zkusí jednou obnovit token a request zopakovat. Chybové statusy + NEvyhazuje — o mapování rozhoduje volající (typované endpointy mapují, + generická proxy předává 1:1). + """ + token = await get_access_token(creds) + + for attempt in (1, 2): + headers: dict[str, str] = {"Authorization": f"Bearer {token}"} + if creds.accept_language: + headers["Accept-Language"] = creds.accept_language + if content_type and content is not None: + headers["Content-Type"] = content_type + + await _throttle() + try: + resp = await _http().request( + method, + creds.base_url + path, + params=params, + json=json_body, + content=content, + files=files, + headers=headers, + ) + except httpx.TimeoutException as exc: + log.error("Timeout při volání PPL %s %s", method, path) + raise UpstreamError(f"PPL CPL API neodpovědělo včas ({method} {path}).") from exc + except httpx.HTTPError as exc: + log.error( + "Chyba spojení na PPL %s %s: %s", method, path, exc.__class__.__name__ + ) + raise UpstreamError(f"PPL CPL API je nedostupné ({method} {path}).") from exc + + if resp.status_code == 401 and attempt == 1: + log.info("PPL vrátilo 401, obnovuji token a opakuji request.") + token_cache.invalidate(creds.cache_key) + token = await get_access_token(creds, force_refresh=True) + continue + + if resp.status_code >= 400: + log.warning("PPL %s %s -> HTTP %s", method, path, resp.status_code) + else: + log.info("PPL %s %s -> HTTP %s", method, path, resp.status_code) + return resp + + raise UpstreamError("PPL CPL API opakovaně odmítlo request.") # pragma: no cover + + +def _relay_headers(resp: httpx.Response) -> dict[str, str]: + return { + name: value + for name, value in resp.headers.items() + if name.lower() in _RELAY_HEADERS + } + + +def ensure_success(resp: httpx.Response) -> None: + """Vyhodí typovanou chybu služby, pokud PPL vrátilo chybový status.""" + if resp.status_code >= 400: + raise_for_upstream( + resp.status_code, resp.text, resp.headers.get("content-type", "") + ) + + +def relay_json(resp: httpx.Response) -> JSONResponse: + """Předá JSON odpověď PPL klientovi vč. paging hlaviček. Chyby mapuje.""" + ensure_success(resp) + body = None + if resp.content: + body = resp.json() + return JSONResponse( + status_code=resp.status_code, content=body, headers=_relay_headers(resp) + ) + + +def relay_binary(resp: httpx.Response) -> Response: + """Předá binární odpověď PPL (etikety PDF/ZPL/JPG...) klientovi. Chyby mapuje.""" + ensure_success(resp) + return Response( + content=resp.content, + status_code=resp.status_code, + media_type=resp.headers.get("content-type", "application/octet-stream"), + headers=_relay_headers(resp), + ) + + +def relay_raw(resp: httpx.Response) -> Response: + """Předá odpověď PPL 1:1 (vč. chybových statusů) — pro generickou proxy.""" + return Response( + content=resp.content, + status_code=resp.status_code, + media_type=resp.headers.get("content-type"), + headers=_relay_headers(resp), + ) + + +def batch_id_from_location(resp: httpx.Response) -> str: + """Vytáhne batchId z Location hlavičky odpovědi POST shipment/order batch.""" + location = resp.headers.get("location", "") + if not location: + log.error("PPL nevrátilo Location hlavičku u batch requestu.") + raise UpstreamError("PPL nevrátilo Location hlavičku s batchId.") + return location.rstrip("/").split("/")[-1] diff --git a/app/credentials.py b/app/credentials.py new file mode 100644 index 0000000..89a0289 --- /dev/null +++ b/app/credentials.py @@ -0,0 +1,80 @@ +"""Extrakce per-request přihlašovacích údajů z X- hlaviček. + +Secrets (ClientId/ClientSecret vydané PPL) chodí VÝHRADNĚ v hlavičkách, +nikdy v těle requestu ani v URL. Nic se neukládá — služba je stateless; +jedinou výjimkou je in-memory cache OAuth tokenů (viz token_cache.py), +klíčovaná hashem údajů, protože PPL limituje vydávání tokenů na 12/min. +""" +import hashlib +from dataclasses import dataclass + +from fastapi import Header + +from .config import CPL_DEFAULT_ENVIRONMENT, CPL_PRODUCTION_BASE_URL, CPL_TEST_BASE_URL +from .errors import BadRequestError, CredentialsError + +_ENVIRONMENTS = ("production", "test") + + +@dataclass +class Credentials: + client_id: str | None + client_secret: str | None + environment: str + accept_language: str | None = None + + def require(self) -> None: + if not self.client_id or not self.client_id.strip(): + raise CredentialsError("Chybí hlavička X-Client-Id.") + if not self.client_secret or not self.client_secret.strip(): + raise CredentialsError("Chybí hlavička X-Client-Secret.") + + @property + def base_url(self) -> str: + if self.environment == "test": + return CPL_TEST_BASE_URL + return CPL_PRODUCTION_BASE_URL + + @property + def cache_key(self) -> str: + """Klíč do token cache — hash, aby se secrets nikde neobjevily v paměti navíc.""" + raw = f"{self.client_id}|{self.client_secret}|{self.environment}" + return hashlib.sha256(raw.encode("utf-8")).hexdigest() + + +def get_credentials( + x_client_id: str | None = Header( + default=None, + alias="X-Client-Id", + description="PPL CPL ClientId (secret, přiděluje PPL).", + ), + x_client_secret: str | None = Header( + default=None, + alias="X-Client-Secret", + description="PPL CPL ClientSecret (secret, přiděluje PPL).", + ), + x_environment: str | None = Header( + default=None, + alias="X-Environment", + description=( + "Cílové prostředí PPL: `production` nebo `test`. " + f"Bez hlavičky se použije `{CPL_DEFAULT_ENVIRONMENT}`." + ), + ), + accept_language: str | None = Header( + default=None, + alias="Accept-Language", + description="Volitelný jazyk odpovědí PPL (např. `cs-CZ`). Předává se dál.", + ), +) -> Credentials: + environment = (x_environment or CPL_DEFAULT_ENVIRONMENT).strip().lower() + if environment not in _ENVIRONMENTS: + raise BadRequestError( + f"Neplatná hodnota X-Environment '{environment}'. Povolené: production, test." + ) + return Credentials( + client_id=x_client_id, + client_secret=x_client_secret, + environment=environment, + accept_language=accept_language, + ) diff --git a/app/errors.py b/app/errors.py new file mode 100644 index 0000000..4603667 --- /dev/null +++ b/app/errors.py @@ -0,0 +1,140 @@ +"""Typované výjimky + centrální exception handlery. + +Platí pravidlo: žádná tichá selhání — každá chyba se loguje (bez secrets). +Chyby upstreamu (PPL CPL API) se mapují na stejné/odpovídající HTTP statusy, +tělo problem+json z PPL se předává v poli `detail`, ať klient vidí přesnou příčinu. +""" +from typing import Any + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse + +from .logging_config import get_logger + +log = get_logger("pplcpl.errors") + + +class CplServiceError(Exception): + """Základní chyba služby s HTTP status kódem.""" + + status_code = 500 + + def __init__(self, message: str, status_code: int | None = None, detail: Any = None): + super().__init__(message) + self.message = message + if status_code is not None: + self.status_code = status_code + self.detail = detail + + +class CredentialsError(CplServiceError): + """Chybějící / PPL odmítnuté přihlašovací údaje (X- hlavičky).""" + + status_code = 401 + + +class ForbiddenError(CplServiceError): + """PPL odmítlo přístup (chybí oprávnění / role k dané metodě).""" + + status_code = 403 + + +class BadRequestError(CplServiceError): + """Neplatný vstup — validační chyba na straně PPL nebo této služby.""" + + status_code = 400 + + +class NotFoundError(CplServiceError): + """Záznam (batch, zásilka, objednávka) v PPL neexistuje.""" + + status_code = 404 + + +class RateLimitError(CplServiceError): + """Upstream rate limit (HTTP 429).""" + + status_code = 429 + + +class UpstreamError(CplServiceError): + """Výpadek / neočekávaná chyba PPL CPL API.""" + + status_code = 502 + + +def _parse_detail(body: str, content_type: str) -> Any: + """Problem+json z PPL vracíme jako objekt, jiná těla jako ořezaný text.""" + if "json" in (content_type or ""): + import json + + try: + return json.loads(body) + except ValueError: + log.warning("PPL vrátilo nevalidní JSON v chybové odpovědi.") + return (body or "")[:1000] + + +def raise_for_upstream(status: int, body: str, content_type: str = "") -> None: + """Zmapuje chybový HTTP status z PPL CPL API na správnou chybu služby. + + - 400/422 → 400 (validační chyba — detail obsahuje problem+json z PPL) + - 401 → 401 (PPL odmítlo token / přihlašovací údaje) + - 403 → 403 (chybějící oprávnění k metodě) + - 404 → 404 (batch / zásilka / objednávka neexistuje) + - 429 → 429 (rate limit) + - jinak → 502 (výpadek / neočekávaná chyba PPL) + """ + detail = _parse_detail(body, content_type) + if status in (400, 422): + raise BadRequestError( + f"PPL CPL API odmítlo požadavek (HTTP {status}) — validační chyba.", + detail=detail, + ) + if status == 401: + raise CredentialsError( + "PPL CPL API odmítlo přihlašovací údaje / token (HTTP 401).", + detail=detail, + ) + if status == 403: + raise ForbiddenError( + "PPL CPL API odmítlo přístup (HTTP 403) — chybí oprávnění k metodě.", + detail=detail, + ) + if status == 404: + raise NotFoundError( + "Záznam v PPL CPL API neexistuje (HTTP 404).", + detail=detail, + ) + if status == 429: + raise RateLimitError("PPL CPL API rate limit (HTTP 429).", detail=detail) + raise UpstreamError(f"PPL CPL API vrátilo chybu {status}.", detail=detail) + + +def register_exception_handlers(app: FastAPI) -> None: + @app.exception_handler(CplServiceError) + async def _handle_service_error(request: Request, exc: CplServiceError): + log.warning( + "%s on %s: %s", + exc.__class__.__name__, + request.url.path, + exc.message, + ) + body = {"error": exc.__class__.__name__, "message": exc.message} + if exc.detail is not None: + body["detail"] = exc.detail + return JSONResponse(status_code=exc.status_code, content=body) + + @app.exception_handler(Exception) + async def _handle_unexpected(request: Request, exc: Exception): + # Nelogujeme celý stack s možnými secrets ve vstupu; logujeme typ + zprávu. + log.error( + "Unhandled %s on %s: %s", + exc.__class__.__name__, + request.url.path, + exc, + ) + return JSONResponse( + status_code=500, + content={"error": "InternalError", "message": "Neočekávaná chyba serveru."}, + ) diff --git a/app/logging_config.py b/app/logging_config.py new file mode 100644 index 0000000..2df61b8 --- /dev/null +++ b/app/logging_config.py @@ -0,0 +1,16 @@ +"""Centrální logging. Nikdy nelogujeme secrets (ClientId/ClientSecret, tokeny).""" +import logging +import os + +_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper() + + +def configure_logging() -> None: + logging.basicConfig( + level=_LEVEL, + format="%(asctime)s %(levelname)s [%(name)s] %(message)s", + ) + + +def get_logger(name: str) -> logging.Logger: + return logging.getLogger(name) diff --git a/app/main.py b/app/main.py index 0287072..b67b94a 100644 --- a/app/main.py +++ b/app/main.py @@ -1,25 +1,94 @@ +"""Vstupní bod aplikace pplcplapi. + +Stateless FastAPI služba běžící v AppFactory za reverse proxy `/apps/`. +Multi-tenant proxy nad PPL CPL API (Create Package Label): tvorba zásilek +a tisk etiket, tracking, objednávky svozu, číselníky, výdejní místa. + +Přihlašovací údaje PPL se předávají per-request v X- hlavičkách, nikdy se +neukládají ani nelogují. Jedinou výjimkou je in-memory cache OAuth tokenů +(PPL limituje vydávání tokenů na 12/min), klíčovaná hashem údajů. +""" import os +from contextlib import asynccontextmanager + from fastapi import FastAPI -APP_NAME = os.getenv("APP_NAME", "PPL CPL API") -APP_VERSION = os.getenv("APP_VERSION", "1.0.0") -ROOT_PATH = os.getenv("ROOT_PATH", "") +from .config import APP_NAME, APP_VERSION, ROOT_PATH +from .cpl_client import close_client +from .errors import register_exception_handlers +from .logging_config import configure_logging +from .routers import codelists, customer, lookups, meta, orders, proxy, shipments + +configure_logging() + +DESCRIPTION = """ +Proxy nad **PPL CPL API** (Create Package Label) — tvorba zásilek a etiket, +tracking, objednávky svozu, číselníky, výdejní místa a našeptávač adres. + +### Přihlašovací údaje (hlavičky) +Secrets se předávají v hlavičkách u každého requestu — nikdy v těle ani v URL: + +- `X-Client-Id` — PPL CPL ClientId +- `X-Client-Secret` — PPL CPL ClientSecret +- `X-Environment` — volitelně `production` (default) nebo `test` + +Služba si sama vyžádá a cachuje OAuth Bearer token (platnost 30 min, +PPL limit 12 tokenů/min) a dodržuje minimální rozestup requestů 40 ms. + +### Asynchronní tok zásilek +CPL API zpracovává zásilky i objednávky dávkově: +`POST /shipments/batch` vrátí `batchId` → stav se sleduje přes +`GET /shipments/batch/{batchId}` → etikety přes `GET /shipments/batch/{batchId}/labels`. +Pro jednoduché použití slouží `POST /shipments/create-and-wait` +(a `POST /orders/create-and-wait`), které celý tok provedou v jednom requestu. + +### Diakritika +CPL API přijímá pouze Latin znaky bez diakritiky. Texty v tělech create +requestů se defaultně transliterují (`Jiří` → `Jiri`); vypnout lze query +parametrem `transliterate=false`. + +### Generická proxy +Cokoliv, co nemá vlastní endpoint: `/proxy/{cesta}` předá request 1:1 na CPL +API s doplněnou autentizací (např. `GET /proxy/codelist/product?Limit=10&Offset=0`). + +### Chyby +JSON `{error, message, detail}` — `detail` obsahuje problem+json z PPL. +400 = validační chyba, 401 = chybějící/odmítnuté přihlašovací údaje, +403 = chybí oprávnění, 404 = záznam neexistuje, 429 = rate limit, +502 = výpadek PPL. +""" + + +@asynccontextmanager +async def lifespan(app: FastAPI): + yield + await close_client() + app = FastAPI( title=APP_NAME, version=APP_VERSION, - root_path=ROOT_PATH + description=DESCRIPTION, + root_path=ROOT_PATH, + lifespan=lifespan, ) -@app.get("/health") -def health(): - return {"status": "ok"} +register_exception_handlers(app) -@app.get("/version") -def version(): - return { - "app": APP_NAME, - "version": APP_VERSION, - "language": "python", - "root_path": ROOT_PATH - } +app.include_router(meta.router) +app.include_router(shipments.router) +app.include_router(orders.router) +app.include_router(codelists.router) +app.include_router(lookups.router) +app.include_router(customer.router) +app.include_router(proxy.router) + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run( + "app.main:app", + host="0.0.0.0", + port=int(os.getenv("PORT", "8000")), + ) diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routers/codelists.py b/app/routers/codelists.py new file mode 100644 index 0000000..d162f4e --- /dev/null +++ b/app/routers/codelists.py @@ -0,0 +1,69 @@ +"""Číselníky CPL API + informace o verzích a stavu API. + +Všechny číselníky jsou GET s povinným stránkováním (Limit/Offset) a vracejí +X-Paging-* hlavičky, které služba předává dál. +""" +from enum import Enum + +from fastapi import APIRouter, Depends, Query + +from ..cpl_client import cpl_request, relay_json +from ..credentials import Credentials, get_credentials + +router = APIRouter(tags=["codelists"]) + + +class Codelist(str, Enum): + """Názvy číselníků dle CPL API (tvoří cestu /codelist/{name}).""" + + ageCheck = "ageCheck" + product = "product" + externalNumber = "externalNumber" + country = "country" + currency = "currency" + service = "service" + servicePriceLimit = "servicePriceLimit" + shipmentPhase = "shipmentPhase" + status = "status" + validationMessage = "validationMessage" + proofOfIdentityType = "proofOfIdentityType" + documentFileType = "documentFileType" + + +@router.get("/codelists/{codelist}", summary="Číselník CPL API") +async def get_codelist( + codelist: Codelist, + limit: int = Query(default=1000, ge=1, le=1000), + offset: int = Query(default=0, ge=0), + creds: Credentials = Depends(get_credentials), +): + """`GET codelist/{name}` — např. product (produkty), country (země + povolení COD), + currency (měny), service (služby), servicePriceLimit (min/max hodnoty služeb), + status (statusy zásilky), validationMessage (chybové kódy).""" + resp = await cpl_request( + creds, + "GET", + f"/codelist/{codelist.value}", + params={"Limit": limit, "Offset": offset}, + ) + return relay_json(resp) + + +@router.get("/version-information", summary="Novinky a změny verzí CPL API") +async def version_information( + limit: int = Query(default=100, ge=1, le=1000), + offset: int = Query(default=0, ge=0), + creds: Credentials = Depends(get_credentials), +): + """`GET versionInformation` — přehled novinek/změn API publikovaných PPL.""" + resp = await cpl_request( + creds, "GET", "/versionInformation", params={"Limit": limit, "Offset": offset} + ) + return relay_json(resp) + + +@router.get("/cpl-info", summary="Stav a verze CPL API (upstream /info)") +async def cpl_info(creds: Credentials = Depends(get_credentials)): + """`GET info` — rychlé ověření, že PPL CPL API běží (verze, stav, čas serveru).""" + resp = await cpl_request(creds, "GET", "/info") + return relay_json(resp) diff --git a/app/routers/customer.py b/app/routers/customer.py new file mode 100644 index 0000000..947dd27 --- /dev/null +++ b/app/routers/customer.py @@ -0,0 +1,35 @@ +"""Zákaznická data: bankovní účty (měny pro dobírku), adresy, číselné řady.""" +from fastapi import APIRouter, Body, Depends + +from ..cpl_client import cpl_request, relay_json +from ..credentials import Credentials, get_credentials + +router = APIRouter(prefix="/customer", tags=["customer"]) + + +@router.get("", summary="Informace k zákazníkovi (účty, měny pro dobírku)") +async def customer_info(creds: Credentials = Depends(get_credentials)): + """`GET customer` — registrované bankovní účty vč. měny, země a SWIFT.""" + resp = await cpl_request(creds, "GET", "/customer") + return relay_json(resp) + + +@router.get("/addresses", summary="Registrované adresy zákazníka") +async def customer_addresses(creds: Credentials = Depends(get_credentials)): + """`GET customer/address` — adresy registrované u PPL (kód, adresa, default).""" + resp = await cpl_request(creds, "GET", "/customer/address") + return relay_json(resp) + + +@router.post("/number-range", summary="Založení číselné řady zásilek") +async def create_number_range( + body: dict = Body( + ..., + examples=[{"productType": "BUSS", "quantity": 100}], + ), + creds: Credentials = Depends(get_credentials), +): + """`POST customer/numberRange` — přidělí rozsah čísel zásilek + (packNumberFrom–packNumberTo) pro daný productType.""" + resp = await cpl_request(creds, "POST", "/customer/numberRange", json_body=body) + return relay_json(resp) diff --git a/app/routers/lookups.py b/app/routers/lookups.py new file mode 100644 index 0000000..6dfc421 --- /dev/null +++ b/app/routers/lookups.py @@ -0,0 +1,126 @@ +"""Vyhledávací metody: výdejní místa, našeptávač adres, routing, tisková data.""" +from typing import Any + +from fastapi import APIRouter, Depends, Query + +from ..cpl_client import cpl_request, relay_binary, relay_json +from ..credentials import Credentials, get_credentials + +router = APIRouter(tags=["lookups"]) + + +@router.get("/access-points", summary="Seznam výdejních míst (ParcelShop/ParcelBox/AlzaBox)") +async def access_points( + country_code: str = Query(..., alias="countryCode", description="Kód země, např. CZ."), + limit: int = Query(default=100, ge=1, le=1000), + offset: int = Query(default=0, ge=0), + access_point_code: str | None = Query(default=None, alias="accessPointCode"), + zip_code: str | None = Query(default=None, alias="zipCode"), + city: str | None = Query(default=None), + access_point_types: list[str] | None = Query( + default=None, + alias="accessPointTypes", + description="ParcelShop, ParcelBox, AlzaBox.", + ), + latitude: float | None = Query(default=None), + longitude: float | None = Query(default=None), + radius: float | None = Query(default=None, description="Poloměr hledání v km."), + pickup_enabled: bool | None = Query(default=None, alias="pickupEnabled"), + active_card_payment: bool | None = Query(default=None, alias="activeCardPayment"), + active_cash_payment: bool | None = Query(default=None, alias="activeCashPayment"), + sizes: list[str] | None = Query(default=None, description="S, M, L, XL."), + creds: Credentials = Depends(get_credentials), +): + """`GET accessPoint` — výdejní místa vč. otevírací doby, GPS a kapacit.""" + params: dict[str, Any] = { + "CountryCode": country_code, + "Limit": limit, + "Offset": offset, + } + if access_point_code: + params["AccessPointCode"] = access_point_code + if zip_code: + params["ZipCode"] = zip_code + if city: + params["City"] = city + if access_point_types: + params["AccessPointTypes"] = access_point_types + if latitude is not None: + params["Latitude"] = latitude + if longitude is not None: + params["Longitude"] = longitude + if radius is not None: + params["Radius"] = radius + if pickup_enabled is not None: + params["PickupEnabled"] = pickup_enabled + if active_card_payment is not None: + params["ActiveCardPayment"] = active_card_payment + if active_cash_payment is not None: + params["ActiveCashPayment"] = active_cash_payment + if sizes: + params["Sizes"] = sizes + resp = await cpl_request(creds, "GET", "/accessPoint", params=params) + return relay_json(resp) + + +@router.get("/address-whisper", summary="Našeptávač adres") +async def address_whisper( + street: str | None = Query(default=None), + zip_code: str | None = Query(default=None, alias="zipCode"), + city: str | None = Query(default=None), + called_from: str | None = Query( + default=None, + alias="calledFrom", + description="Které pole vyvolalo dotaz: Street, ZipCode nebo City.", + ), + creds: Credentials = Depends(get_credentials), +): + """`GET addressWhisper` — validace/doplnění adresy (vrací i pole `valid`).""" + params: dict[str, Any] = {} + if street: + params["Street"] = street + if zip_code: + params["ZipCode"] = zip_code + if city: + params["City"] = city + if called_from: + params["CalledFrom"] = called_from + resp = await cpl_request(creds, "GET", "/addressWhisper", params=params or None) + return relay_json(resp) + + +@router.get("/routing", summary="Směrovací informace pro etiketu") +async def routing( + country: str = Query(..., description="Kód země (povinný), např. CZ."), + street: str | None = Query(default=None), + city: str | None = Query(default=None), + zip_code: str | None = Query(default=None, alias="zipCode"), + product_type: str | None = Query(default=None, alias="productType"), + parcel_shop_code: str | None = Query(default=None, alias="parcelShopCode"), + creds: Credentials = Depends(get_credentials), +): + """`GET routing` — kód trasy, depo a pozice v depu pro danou adresu.""" + params: dict[str, Any] = {"Country": country} + if street: + params["Street"] = street + if city: + params["City"] = city + if zip_code: + params["ZipCode"] = zip_code + if product_type: + params["ProductType"] = product_type + if parcel_shop_code: + params["ParcelShopCode"] = parcel_shop_code + resp = await cpl_request(creds, "GET", "/routing", params=params) + return relay_json(resp) + + +@router.get("/data/{data_guid}", summary="Tisková data etikety (binární)") +async def get_data( + data_guid: str, + creds: Credentials = Depends(get_credentials), +): + """`GET data/{dataGuid}` — stažení jednotlivé etikety přes labelUrl guid + (používá se hlavně po změně formátu Pdf -> Zpl).""" + resp = await cpl_request(creds, "GET", f"/data/{data_guid}") + return relay_binary(resp) diff --git a/app/routers/meta.py b/app/routers/meta.py new file mode 100644 index 0000000..46db80c --- /dev/null +++ b/app/routers/meta.py @@ -0,0 +1,21 @@ +"""Povinné meta endpointy: /health a /version.""" +from fastapi import APIRouter + +from ..config import APP_NAME, APP_VERSION, ROOT_PATH + +router = APIRouter(tags=["meta"]) + + +@router.get("/health", summary="Health check") +def health(): + return {"status": "ok"} + + +@router.get("/version", summary="Verze a runtime informace") +def version(): + return { + "app": APP_NAME, + "version": APP_VERSION, + "language": "python", + "root_path": ROOT_PATH, + } diff --git a/app/routers/orders.py b/app/routers/orders.py new file mode 100644 index 0000000..47ecb64 --- /dev/null +++ b/app/routers/orders.py @@ -0,0 +1,224 @@ +"""Objednávky přepravy / svozu — tvorba (batch), stav, vyhledání, zrušení. + +Stejně jako zásilky jsou objednávky asynchronní: POST order/batch vrátí batchId +(Location hlavička), stav zpracování se polluje přes GET order/batch/{batchId}. + +Typy objednávek (pole orderType v těle): +- CollectionOrder — svoz z registrované adresy zákazníka (bez recipient) +- TransportOrder — přeprava z libovolné adresy (sender i recipient povinné) +""" +import asyncio +import time +from typing import Any + +from fastapi import APIRouter, Body, Depends, Query + +from ..config import ( + BATCH_POLL_INTERVAL_SECONDS, + BATCH_WAIT_TIMEOUT_SECONDS, + TRANSLITERATE_DEFAULT, +) +from ..cpl_client import ( + batch_id_from_location, + cpl_request, + ensure_success, + relay_json, +) +from ..credentials import Credentials, get_credentials +from ..logging_config import get_logger +from ..transliterate import transliterate_json + +log = get_logger("pplcpl.orders") + +router = APIRouter(prefix="/orders", tags=["orders"]) + +_EXAMPLE_ORDER_BODY = { + "orders": [ + { + "orderType": "TransportOrder", + "referenceId": "ORD-0001", + "productType": "BUSS", + "shipmentCount": 1, + "sendDate": "2026-07-17", + "customerReference": "Zakazka 123", + "email": "odesilatel@example.com", + "sender": { + "name": "Firma s.r.o.", + "street": "Prazska 123/4", + "city": "Praha", + "zipCode": "10000", + "country": "CZ", + "phone": "+420601123456", + }, + "recipient": { + "name": "Jan Novak", + "street": "Brnenska 10", + "city": "Brno", + "zipCode": "60200", + "country": "CZ", + "phone": "+420602123456", + }, + } + ] +} + +_PENDING_STATES = ("Accepted", "InProcess") + + +def _maybe_transliterate(body: dict, transliterate: bool | None) -> dict: + apply = TRANSLITERATE_DEFAULT if transliterate is None else transliterate + return transliterate_json(body) if apply else body + + +@router.post( + "/batch", + status_code=201, + summary="Vytvoření objednávky přepravy / svozu (asynchronní)", +) +async def create_order_batch( + body: dict = Body(..., examples=[_EXAMPLE_ORDER_BODY]), + transliterate: bool | None = Query(default=None), + creds: Credentials = Depends(get_credentials), +): + """`POST order/batch` — vrací batchId z Location hlavičky (max 100 objednávek).""" + resp = await cpl_request( + creds, "POST", "/order/batch", json_body=_maybe_transliterate(body, transliterate) + ) + ensure_success(resp) + return { + "batchId": batch_id_from_location(resp), + "location": resp.headers.get("location"), + "correlationId": resp.headers.get("x-correlation-id"), + } + + +@router.get("/batch/{batch_id}", summary="Stav zpracování objednávky (batch)") +async def get_order_batch_status( + batch_id: str, + creds: Credentials = Depends(get_credentials), +): + """`GET order/batch/{batchId}` — importState: Accepted/InProcess/Complete/Error.""" + resp = await cpl_request(creds, "GET", f"/order/batch/{batch_id}") + return relay_json(resp) + + +@router.post( + "/create-and-wait", + summary="Vytvoření objednávky a počkání na zpracování (synchronní obálka)", +) +async def create_order_and_wait( + body: dict = Body(..., examples=[_EXAMPLE_ORDER_BODY]), + timeout_seconds: float = Query(default=BATCH_WAIT_TIMEOUT_SECONDS, ge=1, le=120), + transliterate: bool | None = Query(default=None), + creds: Credentials = Depends(get_credentials), +): + """Convenience: POST order/batch + polling stavu v jednom requestu. + `completed=false` znamená timeout — zpracování v PPL běží dál.""" + create_resp = await cpl_request( + creds, "POST", "/order/batch", json_body=_maybe_transliterate(body, transliterate) + ) + ensure_success(create_resp) + batch_id = batch_id_from_location(create_resp) + + deadline = time.monotonic() + timeout_seconds + status_data: dict = {} + completed = False + while True: + status_resp = await cpl_request(creds, "GET", f"/order/batch/{batch_id}") + ensure_success(status_resp) + status_data = status_resp.json() or {} + items = status_data.get("items") or [] + pending = [i for i in items if i.get("importState") in _PENDING_STATES] + if items and not pending: + completed = True + break + if time.monotonic() >= deadline: + log.warning( + "Order batch %s nebyl zpracován do %ss, vracím completed=false.", + batch_id, + timeout_seconds, + ) + break + await asyncio.sleep(BATCH_POLL_INTERVAL_SECONDS) + + return {"batchId": batch_id, "completed": completed, **status_data} + + +@router.get("", summary="Vyhledání objednávek přepravy") +async def find_orders( + shipment_numbers: list[str] | None = Query(default=None, alias="shipmentNumbers"), + customer_references: list[str] | None = Query( + default=None, alias="customerReferences" + ), + order_references: list[str] | None = Query( + default=None, + alias="orderReferences", + description="referenceId hodnoty z POST order/batch.", + ), + order_numbers: list[str] | None = Query(default=None, alias="orderNumbers"), + order_ids: list[int] | None = Query(default=None, alias="orderIds"), + date_from: str | None = Query(default=None, alias="dateFrom"), + date_to: str | None = Query(default=None, alias="dateTo"), + send_date: str | None = Query(default=None, alias="sendDate"), + product_type: str | None = Query(default=None, alias="productType"), + order_states: str | None = Query( + default=None, + alias="orderStates", + description="None, Created, PickedUp, NotPickedUp, Canceled.", + ), + order_type: str | None = Query( + default=None, alias="orderType", description="CollectionOrder / TransportOrder." + ), + limit: int = Query(default=100, ge=1, le=1000), + offset: int = Query(default=0, ge=0), + creds: Credentials = Depends(get_credentials), +): + """`GET order` — informace o objednávkách vč. stavu a přidělených čísel zásilek.""" + params: dict[str, Any] = {"Limit": limit, "Offset": offset} + if shipment_numbers: + params["ShipmentNumbers"] = shipment_numbers + if customer_references: + params["CustomerReferences"] = customer_references + if order_references: + params["OrderReferences"] = order_references + if order_numbers: + params["OrderNumbers"] = order_numbers + if order_ids: + params["OrderIds"] = order_ids + if date_from: + params["DateFrom"] = date_from + if date_to: + params["DateTo"] = date_to + if send_date: + params["SendDate"] = send_date + if product_type: + params["ProductType"] = product_type + if order_states: + params["OrderStates"] = order_states + if order_type: + params["OrderType"] = order_type + resp = await cpl_request(creds, "GET", "/order", params=params) + return relay_json(resp) + + +@router.post("/cancel", summary="Zrušení objednávky svozu / přepravy") +async def cancel_order( + customer_reference: str | None = Query( + default=None, alias="customerReference", description="Reference odesílatele." + ), + order_reference: str | None = Query( + default=None, alias="orderReference", description="Reference objednávky." + ), + body: dict = Body(default={}, examples=[{"note": "Zrušeno zákazníkem"}]), + creds: Credentials = Depends(get_credentials), +): + """`POST order/cancel` — identifikace přes customerReference nebo orderReference.""" + params: dict[str, Any] = {} + if customer_reference: + params["customerReference"] = customer_reference + if order_reference: + params["orderReference"] = order_reference + resp = await cpl_request( + creds, "POST", "/order/cancel", params=params or None, json_body=body or {} + ) + return relay_json(resp) diff --git a/app/routers/proxy.py b/app/routers/proxy.py new file mode 100644 index 0000000..44d72c7 --- /dev/null +++ b/app/routers/proxy.py @@ -0,0 +1,40 @@ +"""Generická proxy na libovolnou metodu PPL CPL API. + +Pokrývá i endpointy, které nemají vlastní typovanou obálku, a budoucí novinky +API bez nutnosti upravovat tuto službu. Služba doplní OAuth token, dodrží +rozestup requestů a odpověď PPL vrátí 1:1 (status, tělo, content-type, +Location a X-Paging-* hlavičky). +""" +from fastapi import APIRouter, Depends, Request + +from ..cpl_client import cpl_request, relay_raw +from ..credentials import Credentials, get_credentials + +router = APIRouter(tags=["proxy"]) + + +@router.api_route( + "/proxy/{cpl_path:path}", + methods=["GET", "POST", "PUT", "PATCH", "DELETE"], + summary="Generické volání libovolné metody CPL API", +) +async def proxy( + cpl_path: str, + request: Request, + creds: Credentials = Depends(get_credentials), +): + """Příklad: `GET /proxy/codelist/product?Limit=10&Offset=0` zavolá + `GET {base}/codelist/product?Limit=10&Offset=0` s doplněným Bearer tokenem. + + Tělo requestu (JSON i jiné) se předává beze změny; transliterace diakritiky + se zde NEaplikuje — za obsah odpovídá volající.""" + body = await request.body() + resp = await cpl_request( + creds, + request.method, + "/" + cpl_path.lstrip("/"), + params=list(request.query_params.multi_items()) or None, + content=body if body else None, + content_type=request.headers.get("content-type") if body else None, + ) + return relay_raw(resp) diff --git a/app/routers/shipments.py b/app/routers/shipments.py new file mode 100644 index 0000000..484b2a7 --- /dev/null +++ b/app/routers/shipments.py @@ -0,0 +1,393 @@ +"""Zásilky — tvorba (batch), stav importu, etikety, tracking, storno, úpravy. + +Tok tvorby zásilky v CPL API je asynchronní: +1. POST /shipments/batch -> PPL vrátí batchId (z Location hlavičky) +2. GET /shipments/batch/{batchId} -> polling importState (Accepted/InProcess/Complete/Error) +3. GET /shipments/batch/{batchId}/labels -> binární etikety (PDF/ZPL/JPG...) + +Pro typické použití je k dispozici POST /shipments/create-and-wait, který celý +tok provede v jednom requestu (vytvoří, počká na zpracování, volitelně vrátí +etikety v base64). +""" +import asyncio +import base64 +import time +from typing import Any + +from fastapi import APIRouter, Body, Depends, Query, UploadFile + +from ..config import ( + BATCH_POLL_INTERVAL_SECONDS, + BATCH_WAIT_TIMEOUT_SECONDS, + TRANSLITERATE_DEFAULT, +) +from ..cpl_client import ( + batch_id_from_location, + cpl_request, + ensure_success, + relay_binary, + relay_json, +) +from ..credentials import Credentials, get_credentials +from ..errors import BadRequestError +from ..logging_config import get_logger +from ..transliterate import transliterate_json + +log = get_logger("pplcpl.shipments") + +router = APIRouter(prefix="/shipments", tags=["shipments"]) + +_EXAMPLE_SHIPMENT_BODY = { + "shipments": [ + { + "referenceId": "REF-0001", + "productType": "BUSS", + "note": "Volitelna poznamka", + "sender": { + "name": "Firma s.r.o.", + "street": "Prazska 123/4", + "city": "Praha", + "zipCode": "10000", + "country": "CZ", + "phone": "+420601123456", + "email": "odesilatel@example.com", + }, + "recipient": { + "name": "Jan Novak", + "street": "Brnenska 10", + "city": "Brno", + "zipCode": "60200", + "country": "CZ", + "phone": "+420602123456", + "email": "prijemce@example.com", + }, + } + ], + "labelSettings": { + "format": "Pdf", + "completeLabelSettings": {"isCompleteLabelRequested": True, "pageSize": "A4"}, + }, +} + +_PENDING_STATES = ("Accepted", "InProcess") + + +def _maybe_transliterate(body: dict, transliterate: bool | None) -> dict: + apply = TRANSLITERATE_DEFAULT if transliterate is None else transliterate + return transliterate_json(body) if apply else body + + +@router.post( + "/batch", + status_code=201, + summary="Vytvoření zásilky / sady zásilek (asynchronní)", +) +async def create_shipment_batch( + body: dict = Body(..., examples=[_EXAMPLE_SHIPMENT_BODY]), + transliterate: bool | None = Query( + default=None, + description=( + "Převést diakritiku na ASCII (CPL přijímá jen Latin znaky). " + "Bez zadání se použije default služby." + ), + ), + creds: Credentials = Depends(get_credentials), +): + """Odešle `POST shipment/batch` do PPL. Vrací `batchId` z Location hlavičky — + tím se následně dotazuje stav importu a stahují etikety.""" + resp = await cpl_request( + creds, "POST", "/shipment/batch", json_body=_maybe_transliterate(body, transliterate) + ) + ensure_success(resp) + batch_id = batch_id_from_location(resp) + return { + "batchId": batch_id, + "location": resp.headers.get("location"), + "correlationId": resp.headers.get("x-correlation-id"), + } + + +@router.get("/batch/{batch_id}", summary="Stav importu zásilek v batchi") +async def get_shipment_batch_status( + batch_id: str, + order_by: str | None = Query( + default=None, + alias="orderBy", + description="Řazení: ShipmentNumber nebo ReferenceId, prefix `-` = sestupně.", + ), + creds: Credentials = Depends(get_credentials), +): + """`GET shipment/batch/{batchId}` — importState položek: Accepted, InProcess, + Complete (etikety připraveny), Error (viz errorMessage/errorCode).""" + params = {"OrderBy": order_by} if order_by else None + resp = await cpl_request(creds, "GET", f"/shipment/batch/{batch_id}", params=params) + return relay_json(resp) + + +@router.get( + "/batch/{batch_id}/labels", + summary="Stažení etiket batche (binární PDF/ZPL/JPG...)", +) +async def get_shipment_batch_labels( + batch_id: str, + limit: int = Query(default=200, ge=1, le=200), + offset: int = Query(default=0, ge=0), + page_size: str | None = Query( + default=None, alias="pageSize", description="Default nebo A4." + ), + position: int | None = Query( + default=None, ge=1, le=4, description="Pozice etikety na A4 (1–4)." + ), + order_by: str | None = Query(default=None, alias="orderBy"), + creds: Credentials = Depends(get_credentials), +): + """`GET shipment/batch/{batchId}/label` — vrací etikety jako binární soubor + ve formátu nastaveném při vytvoření batche (labelSettings.format).""" + params: dict[str, Any] = {"Limit": limit, "Offset": offset} + if page_size: + params["PageSize"] = page_size + if position is not None: + params["Position"] = position + if order_by: + params["OrderBy"] = order_by + resp = await cpl_request( + creds, "GET", f"/shipment/batch/{batch_id}/label", params=params + ) + return relay_binary(resp) + + +@router.put( + "/batch/{batch_id}/label-settings", + status_code=204, + summary="Úprava výstupního formátu etikety batche", +) +async def update_label_settings( + batch_id: str, + body: dict = Body( + ..., + examples=[ + { + "labelSettings": {"format": "Zpl", "dpi": 300}, + "returnChannel": {"type": "None"}, + } + ], + ), + creds: Credentials = Depends(get_credentials), +): + """`PUT shipment/batch/{batchId}` — změna formátu (Pdf/Zpl/Jpeg/Png/Svg), + DPI a returnChannel. Pozn.: PPL cachuje etikety 60 s (A4 formáty 5 min).""" + resp = await cpl_request( + creds, "PUT", f"/shipment/batch/{batch_id}", json_body=body + ) + ensure_success(resp) + return None + + +@router.post("/batch/connect-set", summary="Spojení zásilek do sady") +async def connect_shipment_set( + body: dict = Body( + ..., + examples=[ + { + "externalSetNumber": "SET-0001", + "shipmentNumbers": ["40950000001", "40950000002"], + } + ], + ), + creds: Credentials = Depends(get_credentials), +): + """`POST shipment/batch/connectSet` — spojí min. 2 zásilky stejného productType + do sady (nelze s dobírkou, jen před fyzickým naskladněním).""" + resp = await cpl_request(creds, "POST", "/shipment/batch/connectSet", json_body=body) + return relay_json(resp) + + +@router.post( + "/create-and-wait", + summary="Vytvoření zásilky a počkání na zpracování (synchronní obálka)", +) +async def create_shipment_and_wait( + body: dict = Body(..., examples=[_EXAMPLE_SHIPMENT_BODY]), + timeout_seconds: float = Query( + default=BATCH_WAIT_TIMEOUT_SECONDS, ge=1, le=120, + description="Maximální doba čekání na zpracování batche.", + ), + include_labels: bool = Query( + default=False, + description="Po dokončení stáhnout etikety a vrátit je v base64.", + ), + label_page_size: str | None = Query( + default=None, description="PageSize etiket při include_labels (Default/A4)." + ), + transliterate: bool | None = Query(default=None), + creds: Credentials = Depends(get_credentials), +): + """Convenience endpoint: provede celý asynchronní tok CPL v jednom requestu — + POST shipment/batch, polling stavu, volitelně stažení etiket. + + Odpověď obsahuje `completed` (false = vypršel timeout, zpracování běží dál, + stav lze dál sledovat přes GET /shipments/batch/{batchId}).""" + create_resp = await cpl_request( + creds, "POST", "/shipment/batch", json_body=_maybe_transliterate(body, transliterate) + ) + ensure_success(create_resp) + batch_id = batch_id_from_location(create_resp) + + deadline = time.monotonic() + timeout_seconds + status_data: dict = {} + completed = False + while True: + status_resp = await cpl_request(creds, "GET", f"/shipment/batch/{batch_id}") + ensure_success(status_resp) + status_data = status_resp.json() or {} + items = status_data.get("items") or [] + pending = [i for i in items if i.get("importState") in _PENDING_STATES] + if items and not pending: + completed = True + break + if time.monotonic() >= deadline: + log.warning( + "Batch %s nebyl zpracován do %ss, vracím completed=false.", + batch_id, + timeout_seconds, + ) + break + await asyncio.sleep(BATCH_POLL_INTERVAL_SECONDS) + + result: dict[str, Any] = { + "batchId": batch_id, + "completed": completed, + **status_data, + } + + has_ok_item = any( + i.get("importState") == "Complete" for i in (status_data.get("items") or []) + ) + if include_labels and completed and has_ok_item: + params: dict[str, Any] = {"Limit": 200, "Offset": 0} + if label_page_size: + params["PageSize"] = label_page_size + label_resp = await cpl_request( + creds, "GET", f"/shipment/batch/{batch_id}/label", params=params + ) + ensure_success(label_resp) + result["label"] = { + "contentType": label_resp.headers.get("content-type"), + "base64": base64.b64encode(label_resp.content).decode("ascii"), + } + return result + + +@router.get("", summary="Tracking / vyhledání zásilek") +async def track_shipments( + shipment_numbers: list[str] | None = Query( + default=None, alias="shipmentNumbers", description="Čísla zásilek (max 50)." + ), + invoice_numbers: list[str] | None = Query( + default=None, alias="invoiceNumbers", description="Čísla zakázek (max 50)." + ), + customer_references: list[str] | None = Query( + default=None, alias="customerReferences", description="Zákaznické reference (max 50)." + ), + variable_symbols: list[str] | None = Query( + default=None, alias="variableSymbols", description="Variabilní symboly (max 50)." + ), + date_from: str | None = Query(default=None, alias="dateFrom"), + date_to: str | None = Query(default=None, alias="dateTo"), + shipment_states: str | None = Query( + default=None, + alias="shipmentStates", + description="Filtr stavu (např. Delivered, OutForDelivery, NotDelivered).", + ), + limit: int = Query(default=100, ge=1, le=1000), + offset: int = Query(default=0, ge=0), + creds: Credentials = Depends(get_credentials), +): + """`GET shipment` — informace a tracking události k zásilkám.""" + params: dict[str, Any] = {"Limit": limit, "Offset": offset} + if shipment_numbers: + params["ShipmentNumbers"] = shipment_numbers + if invoice_numbers: + params["InvoiceNumbers"] = invoice_numbers + if customer_references: + params["CustomerReferences"] = customer_references + if variable_symbols: + params["VariableSymbols"] = variable_symbols + if date_from: + params["DateFrom"] = date_from + if date_to: + params["DateTo"] = date_to + if shipment_states: + params["ShipmentStates"] = shipment_states + resp = await cpl_request(creds, "GET", "/shipment", params=params) + return relay_json(resp) + + +@router.post( + "/{shipment_number}/cancel", status_code=202, summary="Storno zásilky" +) +async def cancel_shipment( + shipment_number: str, + creds: Credentials = Depends(get_credentials), +): + """`POST shipment/{shipmentNumber}/cancel` — PPL vrací 202 (přijato ke zpracování).""" + resp = await cpl_request(creds, "POST", f"/shipment/{shipment_number}/cancel") + ensure_success(resp) + return {"shipmentNumber": shipment_number, "accepted": True} + + +@router.post("/{shipment_number}/redirect", summary="Úprava kontaktu příjemce") +async def redirect_shipment( + shipment_number: str, + body: dict = Body( + ..., + examples=[ + { + "address": { + "contact": "Jan Novak", + "phone": "+420602123456", + "email": "prijemce@example.com", + } + } + ], + ), + creds: Credentials = Depends(get_credentials), +): + """`POST shipment/{shipmentNumber}/redirect` — úprava kontaktních údajů příjemce.""" + resp = await cpl_request( + creds, "POST", f"/shipment/{shipment_number}/redirect", json_body=body + ) + return relay_json(resp) + + +@router.post( + "/{shipment_number}/documents", summary="Uložení celních dokumentů k zásilce" +) +async def upload_customs_documents( + shipment_number: str, + document_file_type: str = Query( + ..., + alias="documentFileType", + description="Typ dokumentu dle číselníku /codelists/documentFileType.", + ), + files: list[UploadFile] = ..., + creds: Credentials = Depends(get_credentials), +): + """`POST shipment/{shipmentNumber}/documents` — upload celních dokumentů + (pdf, doc(x), xls(x), jpg, jpeg, png, odt, ods, txt; max 5 souborů, 1 MB celkem).""" + if not files: + raise BadRequestError("Nebyl nahrán žádný soubor.") + if len(files) > 5: + raise BadRequestError("PPL přijímá maximálně 5 souborů v jednom requestu.") + upload = [ + ("files", (f.filename, await f.read(), f.content_type or "application/octet-stream")) + for f in files + ] + resp = await cpl_request( + creds, + "POST", + f"/shipment/{shipment_number}/documents", + params={"documentFileType": document_file_type}, + files=upload, + ) + return relay_json(resp) diff --git a/app/token_cache.py b/app/token_cache.py new file mode 100644 index 0000000..6159bb9 --- /dev/null +++ b/app/token_cache.py @@ -0,0 +1,51 @@ +"""In-memory cache OAuth Bearer tokenů PPL CPL API. + +PPL vydá max. 12 tokenů za minutu a token platí 30 minut — generovat token +per-request nelze. Cache je klíčovaná SHA-256 hashem přihlašovacích údajů +(samotné údaje se neukládají) a token se obnovuje s předstihem před expirací. + +Per-key asyncio.Lock brání souběžnému vyžádání tokenu pro stejné údaje +(thundering herd při paralelních requestech). +""" +import asyncio +import time + +from .config import TOKEN_REFRESH_MARGIN_SECONDS + +# cache_key -> (access_token, expires_at_monotonic) +_tokens: dict[str, tuple[str, float]] = {} +_locks: dict[str, asyncio.Lock] = {} + + +def _lock_for(cache_key: str) -> asyncio.Lock: + lock = _locks.get(cache_key) + if lock is None: + lock = asyncio.Lock() + _locks[cache_key] = lock + return lock + + +def get_cached(cache_key: str) -> str | None: + entry = _tokens.get(cache_key) + if entry is None: + return None + token, expires_at = entry + if time.monotonic() >= expires_at: + _tokens.pop(cache_key, None) + return None + return token + + +def store(cache_key: str, token: str, expires_in_seconds: float) -> None: + expires_at = time.monotonic() + max( + expires_in_seconds - TOKEN_REFRESH_MARGIN_SECONDS, 30.0 + ) + _tokens[cache_key] = (token, expires_at) + + +def invalidate(cache_key: str) -> None: + _tokens.pop(cache_key, None) + + +async def acquire_lock(cache_key: str) -> asyncio.Lock: + return _lock_for(cache_key) diff --git a/app/transliterate.py b/app/transliterate.py new file mode 100644 index 0000000..b39bed7 --- /dev/null +++ b/app/transliterate.py @@ -0,0 +1,20 @@ +"""Transliterace diakritiky pro PPL CPL API. + +CPL API přijímá pouze Latin znaky bez diakritiky (A-Z, 0-9, základní interpunkce). +České texty (jména, adresy, poznámky) proto před odesláním převádíme přes +unidecode ("Jiří Dvořák" -> "Jiri Dvorak"). Aplikuje se rekurzivně na všechny +stringové hodnoty v JSON těle; klíče se nemění. +""" +from typing import Any + +from unidecode import unidecode + + +def transliterate_json(value: Any) -> Any: + if isinstance(value, str): + return unidecode(value) + if isinstance(value, list): + return [transliterate_json(item) for item in value] + if isinstance(value, dict): + return {key: transliterate_json(item) for key, item in value.items()} + return value diff --git a/documentation/lookups-codelists.md b/documentation/lookups-codelists.md new file mode 100644 index 0000000..45db0c1 --- /dev/null +++ b/documentation/lookups-codelists.md @@ -0,0 +1,67 @@ +# Číselníky, výdejní místa a pomocné metody + +## Číselníky + +`GET /codelists/{name}?limit=1000&offset=0` → `GET codelist/{name}` v PPL. +Podporované názvy: + +| Název | Obsah | +|---|---| +| `product` | produkty/služby (BUSS, PRIV, COPL, ...) | +| `country` | země + povolení dobírky (COD) | +| `currency` | povolené měny | +| `service` | doplňkové služby k zásilkám | +| `servicePriceLimit` | min/max hodnoty služeb (dobírka, připojištění) | +| `ageCheck` | varianty kontroly věku příjemce (15/18+) | +| `externalNumber` | typy externích čísel (např. CUST) | +| `shipmentPhase` | fáze zásilky | +| `status` | statusy zásilky (tracking) | +| `validationMessage` | chybové kódy a hlášení | +| `proofOfIdentityType` | typy osobních dokladů | +| `documentFileType` | typy dokumentů (celní dokumenty) | + +Stránkování se předává v hlavičkách `X-Paging-Total-Items-Count`, +`X-Paging-Offset`, `X-Paging-Limit`. + +## Výdejní místa + +`GET /access-points?countryCode=CZ&limit=100&offset=0` → `GET accessPoint`. +Filtry: `zipCode`, `city`, `accessPointCode`, `accessPointTypes` +(ParcelShop/ParcelBox/AlzaBox), GPS `latitude`+`longitude`+`radius` (km), +`pickupEnabled`, `activeCardPayment`, `activeCashPayment`, `sizes` (S/M/L/XL). +Odpověď: adresa, otevírací doba, GPS, kapacity dle velikostí. + +## Našeptávač adres + +`GET /address-whisper?street=...&city=...&zipCode=...&calledFrom=Street` +→ `GET addressWhisper`. Vrací kandidáty adres vč. pole `valid` — +vhodné pro validaci adresy před vytvořením zásilky. + +## Routing + +`GET /routing?country=CZ&zipCode=60200&...` → `GET routing`. +Vrací `routeCode`, `depotCode`, `depotPosition`, `region`, `secondWave`. + +## Zákazník + +- `GET /customer` → `GET customer` — bankovní účty (měny pro dobírku, SWIFT) +- `GET /customer/addresses` → `GET customer/address` — registrované adresy +- `POST /customer/number-range` → `POST customer/numberRange` — přidělení + číselné řady zásilek `{productType, quantity}` → `packNumberFrom/To` + +## Ostatní + +- `GET /cpl-info` → `GET info` — stav/verze CPL API +- `GET /version-information` → `GET versionInformation` — novinky API +- `GET /data/{dataGuid}` → `GET data/{dataGuid}` — binární tisková data etikety + +## Generická proxy + +`/proxy/{cesta}` (GET/POST/PUT/PATCH/DELETE) předá request 1:1 na CPL API +s doplněným Bearer tokenem — pokrývá vše, co nemá typovaný endpoint, +i budoucí metody. Příklad: + +``` +GET /proxy/codelist/product?Limit=10&Offset=0 +POST /proxy/shipment/batch (tělo 1:1 dle PPL dokumentace, bez transliterace) +``` diff --git a/documentation/orders.md b/documentation/orders.md new file mode 100644 index 0000000..edc10af --- /dev/null +++ b/documentation/orders.md @@ -0,0 +1,38 @@ +# Objednávky přepravy a svozu + +| Endpoint služby | Upstream CPL | Popis | +|---|---|---| +| `POST /orders/batch` | `POST order/batch` | vytvoření objednávek (max 100), vrací `batchId` | +| `GET /orders/batch/{batchId}` | `GET order/batch/{batchId}` | stav zpracování | +| `POST /orders/create-and-wait` | (kombinace) | synchronní obálka | +| `GET /orders` | `GET order` | vyhledání objednávek | +| `POST /orders/cancel` | `POST order/cancel` | zrušení objednávky | + +## Typy objednávek (`orderType`) + +- **CollectionOrder** — svoz z registrované adresy zákazníka (pravidelní + odesílatelé). `recipient` se neuvádí. +- **TransportOrder** — vyzvednutí z libovolné adresy; `sender` i `recipient` + jsou povinné. + +Povinná pole: `referenceId` (1–50 znaků), `shipmentCount` (1–50), `sendDate`. +Volitelné: `productType` (BUSS domácí / IMPO mezinárodní), `customerReference`, +`email`, `note`, `sendTimeFrom`, `sendTimeTo`. + +## Stav zpracování + +`GET /orders/batch/{batchId}` vrací `items[]` s `importState` +(Accepted/InProcess/Complete/Error) a případným `errorMessage`/`errorCode`. + +## Vyhledání + +`GET /orders?limit=100&offset=0` — filtry: `orderNumbers`, `orderReferences` +(= referenceId z batch requestu), `customerReferences`, `shipmentNumbers`, +`orderIds`, `dateFrom/dateTo`, `sendDate`, `productType`, +`orderStates` (Created, PickedUp, NotPickedUp, Canceled), `orderType`. +Odpověď vrací i přidělená čísla zásilek (`shipmentNumbers`). + +## Zrušení + +`POST /orders/cancel?orderReference=ORD-0001` (nebo `customerReference=...`), +volitelné tělo `{"note": "důvod"}`. diff --git a/documentation/overview.md b/documentation/overview.md new file mode 100644 index 0000000..897cf00 --- /dev/null +++ b/documentation/overview.md @@ -0,0 +1,94 @@ +# PPL CPL API — přehled služby + +Stateless multi-tenant proxy nad PPL CPL API (Create Package Label). +Upstream: `https://api.dhl.com/ecs/ppl/myapi2` (production), +`https://api-dev.dhl.com/ecs/ppl/myapi2` (test). +Dokumentace PPL: https://ppl-cpl-api.apidog.io/ + +## Autentizace + +Každý request nese hlavičky: + +- `X-Client-Id` — PPL ClientId (secret) +- `X-Client-Secret` — PPL ClientSecret (secret) +- `X-Environment` — `production` (default) nebo `test` +- `Accept-Language` — volitelně, předává se do PPL (např. `cs-CZ`) + +Služba z údajů získá OAuth Bearer token (`POST {base}/login/getAccessToken`, +grant `client_credentials`, scope `myapi2`) a **cachuje ho in-memory** pod +SHA-256 hashem údajů. Důvod: PPL vydá max. 12 tokenů/min a token platí 30 minut. +Token se obnovuje 60 s před expirací; na HTTP 401 z PPL se jednou obnoví +a request se zopakuje. + +## Limity PPL, které služba respektuje + +- min. 40 ms rozestup mezi requesty (globální throttle v procesu), +- max. 1000 zásilek / 100 objednávek v jednom batchi, +- batchId platí 30 dní, +- pouze Latin znaky bez diakritiky — viz transliterace níže. + +## Transliterace diakritiky + +CPL API odmítá texty s diakritikou. Těla `POST /shipments/batch`, +`/shipments/create-and-wait`, `/orders/batch` a `/orders/create-and-wait` +se defaultně rekurzivně transliterují přes unidecode (`Jiří Dvořák` → +`Jiri Dvorak`). Chování: + +- query parametr `transliterate=true|false` na requestu má přednost, +- jinak platí env `CPL_TRANSLITERATE` (default `true`), +- generická `/proxy/...` NEtransliteruje nikdy (předává 1:1). + +## Chybové odpovědi + +Jednotný JSON `{error, message, detail}`; `detail` obsahuje původní +problem+json z PPL (typ, title, errors...). + +| Status | Význam | +|---|---| +| 400 | validační chyba (vstup zde nebo v PPL) | +| 401 | chybějící X- hlavičky nebo PPL odmítlo údaje/token | +| 403 | chybí oprávnění (role) k metodě v PPL | +| 404 | batch/zásilka/objednávka neexistuje | +| 429 | rate limit PPL | +| 502 | výpadek / neočekávaná chyba PPL | + +Všechny chyby se logují (bez secrets) — žádná tichá selhání. + +## Konfigurace (environment variables) + +| Proměnná | Default | Význam | +|---|---|---| +| `ROOT_PATH` | `""` | prefix za reverse proxy (`/apps/pplcplapi`) | +| `CPL_DEFAULT_ENVIRONMENT` | `production` | prostředí bez hlavičky X-Environment | +| `CPL_PRODUCTION_BASE_URL` / `CPL_TEST_BASE_URL` | viz výše | base URL upstreamu | +| `CPL_OAUTH_SCOPE` | `myapi2` | OAuth scope | +| `CPL_TRANSLITERATE` | `true` | default transliterace diakritiky | +| `UPSTREAM_TIMEOUT_SECONDS` | `60` | timeout volání PPL | +| `TOKEN_REFRESH_MARGIN_SECONDS` | `60` | předstih obnovy tokenu | +| `MIN_REQUEST_INTERVAL_SECONDS` | `0.04` | rozestup requestů na PPL | +| `BATCH_POLL_INTERVAL_SECONDS` | `1.0` | interval pollingu create-and-wait | +| `BATCH_WAIT_TIMEOUT_SECONDS` | `30` | default timeout create-and-wait | +| `LOG_LEVEL` | `INFO` | úroveň logování | + +Žádné secrets se nekonfigurují přes env — vše chodí per-request v hlavičkách. + +## Struktura kódu + +``` +app/ + main.py — FastAPI aplikace, root_path, popis pro Swagger + config.py — env konfigurace + credentials.py — X- hlavičky -> Credentials (dependency) + token_cache.py — in-memory cache OAuth tokenů + cpl_client.py — autentizovaný httpx klient, throttle, relay helpery + transliterate.py — rekurzivní unidecode JSON těl + errors.py — typované chyby + handlery + routers/ + meta.py — /health, /version + shipments.py — zásilky, etikety, tracking, storno, celní dokumenty + orders.py — objednávky svozu/přepravy + codelists.py — číselníky, /version-information, /cpl-info + lookups.py — /access-points, /address-whisper, /routing, /data/{guid} + customer.py — /customer, /customer/addresses, /customer/number-range + proxy.py — generická proxy /proxy/{cesta} +``` diff --git a/documentation/shipments.md b/documentation/shipments.md new file mode 100644 index 0000000..27451e7 --- /dev/null +++ b/documentation/shipments.md @@ -0,0 +1,67 @@ +# Zásilky + +Tok CPL API je asynchronní (dávkový). Mapování na upstream metody: + +| Endpoint služby | Upstream CPL | Popis | +|---|---|---| +| `POST /shipments/batch` | `POST shipment/batch` | vytvoření zásilek (max 1000), vrací `batchId` | +| `GET /shipments/batch/{batchId}` | `GET shipment/batch/{batchId}` | stav importu | +| `GET /shipments/batch/{batchId}/labels` | `GET shipment/batch/{batchId}/label` | binární etikety | +| `PUT /shipments/batch/{batchId}/label-settings` | `PUT shipment/batch/{batchId}` | změna formátu etiket | +| `POST /shipments/batch/connect-set` | `POST shipment/batch/connectSet` | spojení zásilek do sady | +| `POST /shipments/create-and-wait` | (kombinace výše) | synchronní obálka | +| `GET /shipments` | `GET shipment` | tracking / vyhledání | +| `POST /shipments/{n}/cancel` | `POST shipment/{n}/cancel` | storno (PPL vrací 202) | +| `POST /shipments/{n}/redirect` | `POST shipment/{n}/redirect` | úprava kontaktu příjemce | +| `POST /shipments/{n}/documents` | `POST shipment/{n}/documents` | celní dokumenty (multipart) | + +## Vytvoření zásilky + +`POST /shipments/batch` — tělo se předává 1:1 do PPL (viz příklad ve Swaggeru). +Povinné: `shipments[].referenceId`, `shipments[].productType`, +`shipments[].recipient.zipCode` + `country`. Odpověď: + +```json +{ "batchId": "1c37...", "location": "https://.../shipment/batch/1c37...", "correlationId": "..." } +``` + +Query parametr `transliterate` řídí převod diakritiky (default dle služby). + +### Stav importu + +`GET /shipments/batch/{batchId}` vrací `items[]` s `importState`: +`Accepted` → `InProcess` → `Complete` (etikety připraveny) / `Error` +(`errorMessage`, `errorCode`). Po dokončení obsahuje `labelUrl` per zásilka, +příp. `completeLabel.labelUrls` (souhrnné PDF). + +### Etikety + +`GET /shipments/batch/{batchId}/labels?limit=200&offset=0&pageSize=A4&position=1` +vrací binární soubor ve formátu z `labelSettings.format` +(Pdf / Zpl / Jpeg / Png / Svg; DPI 203–1200). Jednotlivé etikety lze stáhnout +i přes `GET /data/{dataGuid}` (guid z `labelUrl`). + +## create-and-wait (doporučeno pro e-shop scénář) + +`POST /shipments/create-and-wait?timeout_seconds=30&include_labels=true` + +1. vytvoří batch, +2. polluje stav (interval 1 s) dokud vše není Complete/Error nebo nevyprší timeout, +3. s `include_labels=true` stáhne etikety a vrátí je v base64. + +Odpověď: `{batchId, completed, items[], completeLabel, label?: {contentType, base64}}`. +`completed=false` = timeout; zpracování v PPL běží dál, stav lze dosledovat +přes `GET /shipments/batch/{batchId}` (batchId platí 30 dní). + +## Tracking + +`GET /shipments?shipmentNumbers=...&limit=100&offset=0` — filtry: +`shipmentNumbers`, `invoiceNumbers`, `customerReferences`, `variableSymbols` +(vše max 50 hodnot, lze opakovat), `dateFrom`, `dateTo`, `shipmentStates`. +Odpověď obsahuje tracking události; paging v hlavičkách `X-Paging-*`. + +## Celní dokumenty + +`POST /shipments/{n}/documents?documentFileType=...` — multipart pole `files` +(max 5 souborů, 1 MB celkem; pdf, doc(x), xls(x), jpg, jpeg, png, odt, ods, txt). +Typy viz `GET /codelists/documentFileType`. diff --git a/requirements.txt b/requirements.txt index 364e2ee..e9114a1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,5 @@ fastapi uvicorn[standard] +httpx +unidecode +python-multipart