sklik rozsireni na typove metody
This commit is contained in:
@@ -29,7 +29,12 @@ Interactive docs (Swagger UI): `/docs` — publicly `https://services.csbot.cz/a
|
||||
| Google Ads | POST | `/googleads/customers/{id}/search`, `/googleads/customers/{id}/searchStream` |
|
||||
| Google Ads | GET | `/googleads/customers:listAccessibleCustomers` |
|
||||
| Sklik | POST | `/sklik/login`, `/sklik/report/{entity}`, `/sklik/rpc/{method}` |
|
||||
| Sklik | GET | `/sklik/limits` |
|
||||
| Sklik | GET | `/sklik/limits`, `/sklik/write-limits` |
|
||||
| Sklik read | GET | `/sklik/campaigns`, `/sklik/groups`, `/sklik/ads` |
|
||||
| Sklik **write** | POST | `/sklik/{campaigns\|groups\|ads}` — create, always **paused** |
|
||||
| Sklik **write** | PUT | `/sklik/{campaigns\|groups\|ads}` — update by id (partial) |
|
||||
| Sklik **write** | DELETE | `/sklik/{campaigns\|groups\|ads}?ids=` — remove (reversible) |
|
||||
| Sklik **write** | POST | `/sklik/{campaigns\|groups\|ads}/restore?ids=` |
|
||||
|
||||
## Credentials (headers)
|
||||
|
||||
@@ -60,6 +65,14 @@ Interactive docs (Swagger UI): `/docs` — publicly `https://services.csbot.cz/a
|
||||
| --- | --- | --- |
|
||||
| `X-Sklik-Token` | yes | Sklik API token from account settings. |
|
||||
| `X-Sklik-User-Id` | no | Managed account id for agency/MCC access. |
|
||||
| `X-Idempotency-Key` | no | Writes only. A retry with the same key replays the original result instead of creating a duplicate. |
|
||||
|
||||
> **Sklik writes** create everything **paused** (`status: suspend`) — Sklik's own
|
||||
> default is `active`, so this is forced server-side. `PUT` is ordinary CRUD and
|
||||
> *can* resume a campaign; set `SKLIK_BLOCK_ACTIVATION=true` to forbid that.
|
||||
> Budget ceilings are **off by default**; enable them with `SKLIK_MAX_*`.
|
||||
> `DELETE` is Sklik's soft delete and is reversible via `/restore`.
|
||||
> See [documentation/sklik.md](documentation/sklik.md).
|
||||
|
||||
Where to obtain each credential is described at the top of `/docs` (Swagger) and
|
||||
in [documentation/](documentation/).
|
||||
@@ -78,6 +91,13 @@ Non-secret only — see [app/config.py](app/config.py): `ROOT_PATH`,
|
||||
`GA_DATA_BASE_URL`, `GA_ADMIN_BASE_URL`, `GA_SCOPE`, `GSC_DATA_BASE_URL`,
|
||||
`GSC_INSPECT_BASE_URL`, `GSC_SCOPE`, `GOOGLE_ADS_BASE_URL`,
|
||||
`GOOGLE_ADS_API_VERSION`, `GOOGLE_ADS_SCOPE`, `SKLIK_BASE_URL`,
|
||||
`HTTP_TIMEOUT_SECONDS`, `LOG_LEVEL`.
|
||||
`SKLIK_LIST_PAGE_LIMIT`, `SKLIK_LIST_MAX_PAGES`, `HTTP_TIMEOUT_SECONDS`,
|
||||
`LOG_LEVEL`.
|
||||
|
||||
Sklik write guard rails (all optional, all off by default):
|
||||
`SKLIK_MAX_DAY_BUDGET_HALERS`, `SKLIK_MAX_TOTAL_BUDGET_HALERS`,
|
||||
`SKLIK_MAX_CPC_HALERS` (`0` = no ceiling), `SKLIK_IDEMPOTENCY_TTL_SECONDS`,
|
||||
`SKLIK_BLOCK_ACTIVATION` (default `false`),
|
||||
`SKLIK_RPC_ALLOW_MUTATIONS` (default `true`).
|
||||
|
||||
See [documentation/](documentation/) for per-integration detail.
|
||||
|
||||
@@ -133,6 +133,58 @@ class SklikClient:
|
||||
full_args = [self._user_struct()] + list(args or [])
|
||||
return await self._call(method, full_args)
|
||||
|
||||
async def fetch_list(
|
||||
self,
|
||||
entity: str,
|
||||
restriction_filter: dict[str, Any],
|
||||
display_columns: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""Page through ``{entity}.list`` and collect every row.
|
||||
|
||||
``{entity}.list`` takes ``(restrictionFilter, displayOptions)`` and
|
||||
returns the rows under a key named after the entity (``campaigns``,
|
||||
``groups``, ``ads``). Paging is offset/limit, same as readReport.
|
||||
"""
|
||||
rows: list[Any] = []
|
||||
offset = 0
|
||||
pages = 0
|
||||
total: int | None = None
|
||||
|
||||
while True:
|
||||
display: dict[str, Any] = {
|
||||
"offset": offset,
|
||||
"limit": config.SKLIK_LIST_PAGE_LIMIT,
|
||||
}
|
||||
if display_columns:
|
||||
display["displayColumns"] = display_columns
|
||||
|
||||
page = await self.call(f"{entity}.list", [restriction_filter, display])
|
||||
batch = page.get(entity) or []
|
||||
rows.extend(batch)
|
||||
if total is None and isinstance(page.get("totalCount"), int):
|
||||
total = page["totalCount"]
|
||||
|
||||
pages += 1
|
||||
offset += config.SKLIK_LIST_PAGE_LIMIT
|
||||
if len(batch) < config.SKLIK_LIST_PAGE_LIMIT:
|
||||
break
|
||||
if pages >= config.SKLIK_LIST_MAX_PAGES:
|
||||
logger.warning(
|
||||
"Sklik %s.list hit the %d-page safety cap (collected %d "
|
||||
"rows); result is truncated.",
|
||||
entity,
|
||||
config.SKLIK_LIST_MAX_PAGES,
|
||||
len(rows),
|
||||
)
|
||||
break
|
||||
|
||||
return {
|
||||
"totalCount": total if total is not None else len(rows),
|
||||
"returnedCount": len(rows),
|
||||
"truncated": pages >= config.SKLIK_LIST_MAX_PAGES,
|
||||
entity: rows,
|
||||
}
|
||||
|
||||
async def fetch_report(
|
||||
self, entity: str, report_args: list[Any]
|
||||
) -> dict:
|
||||
|
||||
@@ -60,6 +60,47 @@ GOOGLE_ADS_SCOPE = os.getenv(
|
||||
# HTTP body is a JSON array of positional arguments.
|
||||
SKLIK_BASE_URL = os.getenv("SKLIK_BASE_URL", "https://api.sklik.cz/drak/json/v5")
|
||||
|
||||
# Page size / safety cap for the typed list endpoints (campaigns, groups, ads).
|
||||
SKLIK_LIST_PAGE_LIMIT = int(os.getenv("SKLIK_LIST_PAGE_LIMIT", "100"))
|
||||
SKLIK_LIST_MAX_PAGES = int(os.getenv("SKLIK_LIST_MAX_PAGES", "200"))
|
||||
|
||||
# --- Sklik write guard rails --------------------------------------------------
|
||||
# Optional backstops for the write endpoints. All amounts are in HALERS
|
||||
# (100 = 1 Kc), the unit the Sklik API itself uses.
|
||||
#
|
||||
# All THREE ceilings default to 0 = DISABLED, i.e. the proxy does not second-
|
||||
# guess the caller's budgets out of the box. Set a non-zero value to have the
|
||||
# proxy reject anything above it - useful as a safety net against a misplaced
|
||||
# decimal point (Sklik works in halers, so "50000" meant as korunas is 500 Kc).
|
||||
# Enforcement is opt-in on purpose: the approval flow lives on the caller's side.
|
||||
SKLIK_MAX_DAY_BUDGET_HALERS = int(os.getenv("SKLIK_MAX_DAY_BUDGET_HALERS", "0"))
|
||||
SKLIK_MAX_TOTAL_BUDGET_HALERS = int(os.getenv("SKLIK_MAX_TOTAL_BUDGET_HALERS", "0"))
|
||||
# Ceiling for a group's default max CPC / CPT.
|
||||
SKLIK_MAX_CPC_HALERS = int(os.getenv("SKLIK_MAX_CPC_HALERS", "0"))
|
||||
|
||||
# How long a completed write is remembered for idempotent replay (seconds).
|
||||
# Idempotency itself is opt-in per request via the X-Idempotency-Key header.
|
||||
SKLIK_IDEMPOTENCY_TTL_SECONDS = float(
|
||||
os.getenv("SKLIK_IDEMPOTENCY_TTL_SECONDS", "86400")
|
||||
)
|
||||
|
||||
# Creation always forces status=suspend (see app.sklik_guards). UPDATE, however,
|
||||
# is full CRUD and can set status=active by default - that is how you resume a
|
||||
# paused campaign. Set this to "true" to refuse activation through the API as
|
||||
# well, so starting a campaign stays a manual action in the Sklik UI.
|
||||
SKLIK_BLOCK_ACTIVATION = os.getenv(
|
||||
"SKLIK_BLOCK_ACTIVATION", "false"
|
||||
).strip().lower() in ("1", "true", "yes")
|
||||
|
||||
# The generic /sklik/rpc passthrough reaches any method, including mutating ones
|
||||
# (campaigns.create etc.), which bypass the typed endpoints' guard rails. That
|
||||
# is the long-standing behaviour and stays ALLOWED by default. Set this to
|
||||
# "false" to restrict the passthrough to read methods and funnel every write
|
||||
# through the typed endpoints.
|
||||
SKLIK_RPC_ALLOW_MUTATIONS = os.getenv(
|
||||
"SKLIK_RPC_ALLOW_MUTATIONS", "true"
|
||||
).strip().lower() in ("1", "true", "yes")
|
||||
|
||||
# --- HTTP ---------------------------------------------------------------------
|
||||
# Upstream request timeout in seconds.
|
||||
HTTP_TIMEOUT_SECONDS = float(os.getenv("HTTP_TIMEOUT_SECONDS", "60"))
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Idempotency for write operations.
|
||||
|
||||
Why this exists: if a create request times out on the network, the caller cannot
|
||||
know whether the campaign was created. Retrying without protection creates a
|
||||
second campaign that also spends money. An idempotency key makes the retry
|
||||
return the ORIGINAL result instead of doing the work twice.
|
||||
|
||||
Scope and honest limitations - this is an **in-memory, per-container** store:
|
||||
|
||||
* it does not survive a restart or a redeploy;
|
||||
* it is not shared between replicas, so with more than one container a retry
|
||||
can land on a replica that has never seen the key.
|
||||
|
||||
That is a deliberate trade-off matching the stateless design (the same one the
|
||||
Google token cache in ``clients/google.py`` makes). It removes the common
|
||||
failure - an immediate client retry after a timeout - and it is a large
|
||||
improvement over nothing. If Sklik writes ever run at a volume where a duplicated
|
||||
campaign is unacceptable, this needs to move to shared storage (Redis) and that
|
||||
should be a conscious decision, not a surprise. Keys are hashed together with the
|
||||
caller's token so two tenants can never read each other's stored responses.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from . import config
|
||||
from .errors import UpstreamError
|
||||
from .logging_config import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Entry:
|
||||
#: None while the original request is still running.
|
||||
response: Any
|
||||
created_at: float
|
||||
in_flight: bool
|
||||
|
||||
|
||||
_store: dict[str, _Entry] = {}
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def _cache_key(idempotency_key: str, token: str, operation: str) -> str:
|
||||
# Hashed so neither the raw token nor the caller's key sits in a dict key we
|
||||
# might later log. The token is part of the key so tenants stay isolated.
|
||||
raw = f"{idempotency_key}|{token}|{operation}"
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _purge_expired(now: float) -> None:
|
||||
"""Drop entries past their TTL. Called under the lock."""
|
||||
ttl = config.SKLIK_IDEMPOTENCY_TTL_SECONDS
|
||||
expired = [k for k, e in _store.items() if now - e.created_at > ttl]
|
||||
for key in expired:
|
||||
del _store[key]
|
||||
|
||||
|
||||
def begin(idempotency_key: str, token: str, operation: str) -> tuple[str, Any | None]:
|
||||
"""Claim an idempotency key before performing a write.
|
||||
|
||||
Returns ``(cache_key, previous_response)``. A non-None previous response
|
||||
means this exact request already succeeded and must NOT be performed again -
|
||||
return the stored response instead.
|
||||
|
||||
Raises ``UpstreamError`` (409) if an identical request is still in flight,
|
||||
so two concurrent retries cannot both reach Sklik.
|
||||
"""
|
||||
key = _cache_key(idempotency_key, token, operation)
|
||||
now = time.time()
|
||||
with _lock:
|
||||
_purge_expired(now)
|
||||
entry = _store.get(key)
|
||||
if entry is None:
|
||||
_store[key] = _Entry(response=None, created_at=now, in_flight=True)
|
||||
return key, None
|
||||
if entry.in_flight:
|
||||
raise UpstreamError(
|
||||
"A request with this X-Idempotency-Key is still in progress. "
|
||||
"Wait for it to finish rather than retrying.",
|
||||
status=409,
|
||||
)
|
||||
logger.info(
|
||||
"Idempotent replay for %s (key hash %s...) - not calling Sklik again.",
|
||||
operation,
|
||||
key[:12],
|
||||
)
|
||||
return key, entry.response
|
||||
|
||||
|
||||
def complete(cache_key: str, response: Any) -> None:
|
||||
"""Store the successful result so a retry replays it."""
|
||||
with _lock:
|
||||
entry = _store.get(cache_key)
|
||||
if entry is None:
|
||||
# Purged mid-flight (very long request vs. a short TTL). Recreate it
|
||||
# rather than losing the record silently.
|
||||
_store[cache_key] = _Entry(
|
||||
response=response, created_at=time.time(), in_flight=False
|
||||
)
|
||||
logger.warning(
|
||||
"Idempotency entry %s... vanished before completion; recreated.",
|
||||
cache_key[:12],
|
||||
)
|
||||
return
|
||||
entry.response = response
|
||||
entry.in_flight = False
|
||||
|
||||
|
||||
def abandon(cache_key: str) -> None:
|
||||
"""Release a claimed key after a FAILED write, so the caller can retry.
|
||||
|
||||
A failed create must stay retryable: keeping the key would lock the caller
|
||||
out of ever retrying with it.
|
||||
"""
|
||||
with _lock:
|
||||
entry = _store.get(cache_key)
|
||||
if entry is not None and entry.in_flight:
|
||||
del _store[cache_key]
|
||||
+37
@@ -39,6 +39,7 @@ služba si nic neukládá. Vyplníte je v Swaggeru po kliknutí na **Try it out*
|
||||
| Google Ads | `X-GAds-Login-Customer-Id`, `X-GAds-Quota-Project` | ne |
|
||||
| Sklik | `X-Sklik-Token` | ano |
|
||||
| Sklik | `X-Sklik-User-Id` | ne (jen pro agenturní/MCC přístup) |
|
||||
| Sklik – zápis | `X-Idempotency-Key` | ne (doporučeno, viz níže) |
|
||||
|
||||
Tři Google služby používají stejný princip přihlášení (Google OAuth) – liší se
|
||||
jen prefixem hlavičky a oprávněním (scope). **Jeden service account lze použít
|
||||
@@ -115,6 +116,42 @@ standardní hlavičce `Authorization: Bearer <token>`.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Sklik – správa kampaní (zápis)
|
||||
|
||||
Plné CRUD nad kampaněmi, sestavami a inzeráty. Tělo je **pole struktur** podle
|
||||
[dokumentace Skliku](https://api.sklik.cz/drak/campaigns.create.html); dávka je
|
||||
all-or-nothing.
|
||||
|
||||
| Operace | Endpoint |
|
||||
| --- | --- |
|
||||
| Založení | `POST /sklik/{campaigns\\|groups\\|ads}` |
|
||||
| Úprava | `PUT /sklik/{campaigns\\|groups\\|ads}` (povinné `id`) |
|
||||
| Smazání | `DELETE /sklik/{campaigns\\|groups\\|ads}?ids=1,2` |
|
||||
| Obnovení | `POST /sklik/{campaigns\\|groups\\|ads}/restore?ids=1,2` |
|
||||
|
||||
- **Vše se zakládá pauznuté.** Sklik má výchozí `status: active`, takže
|
||||
neuvedený stav by znamenal živou kampaň – proxy proto při zakládání vždy
|
||||
vynutí `status: "suspend"` a jinou hodnotu ignoruje.
|
||||
- **`PUT` status nevynucuje** – tím se kampaň pozastavuje (`suspend`) i
|
||||
spouští (`active`). Pokud má aktivace zůstat výhradně ruční, nastavte
|
||||
`SKLIK_BLOCK_ACTIVATION=true`; pak je `active` odmítnuto s 403.
|
||||
- **Mazání je vratné** – Sklik entitu jen označí jako smazanou, `/restore` ji vrátí.
|
||||
- **U inzerátů** platí, že změna kreativy (nadpisy, popis, URL) starý inzerát
|
||||
smaže a založí nový – **inzerát dostane nové `id`**.
|
||||
- **Částky jsou v haléřích** (100 = 1 Kč). `dayBudget: 20000` = 200 Kč/den.
|
||||
- **`X-Idempotency-Key`** (volitelné, doporučené): při opakovaném odeslání se
|
||||
stejným klíčem se vrátí původní výsledek místo zopakování zápisu.
|
||||
- **Stropy rozpočtů** jsou ve výchozím stavu **vypnuté**. Zapínají se
|
||||
proměnnými `SKLIK_MAX_DAY_BUDGET_HALERS`, `SKLIK_MAX_TOTAL_BUDGET_HALERS`
|
||||
a `SKLIK_MAX_CPC_HALERS`. Co je aktuálně nastavené, ukáže
|
||||
`GET /sklik/write-limits`.
|
||||
|
||||
Číst strukturu účtu lze přes `GET /sklik/campaigns`, `/sklik/groups`,
|
||||
`/sklik/ads` (stránkování řeší proxy), statistiky přes `POST
|
||||
/sklik/report/{entity}`.
|
||||
|
||||
---
|
||||
|
||||
Podrobnosti k jednotlivým endpointům jsou v `documentation/` v repozitáři.
|
||||
""".strip()
|
||||
|
||||
|
||||
+523
-6
@@ -4,17 +4,34 @@ The proxy logs in with X-Sklik-Token per request (client.loginByToken) and then
|
||||
performs the requested call, injecting the session for you. See
|
||||
``app.clients.sklik_client`` and https://api.sklik.cz/drak/ for methods.
|
||||
|
||||
Credentials: X-Sklik-Token (required).
|
||||
Three groups of endpoints:
|
||||
|
||||
* **read** - typed listing of campaigns / groups / ads, plus the existing
|
||||
stats report helper;
|
||||
* **write** - full CRUD over campaigns / groups / ads: create, update, remove
|
||||
and restore. Creation always forces a **paused** status; update is ordinary
|
||||
CRUD and can also resume a campaign. Removal is a soft delete, so every
|
||||
remove has a matching restore. Budget ceilings and idempotency are available
|
||||
but optional - see ``app.sklik_guards`` and ``app.idempotency``;
|
||||
* **generic RPC** - anything else, including mutating methods. Unrestricted by
|
||||
default; set ``SKLIK_RPC_ALLOW_MUTATIONS=false`` to funnel every write
|
||||
through the typed endpoints instead.
|
||||
|
||||
Credentials: X-Sklik-Token (required), X-Sklik-User-Id (optional).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Header, Path
|
||||
from fastapi import APIRouter, Body, Depends, Header, Path, Query
|
||||
|
||||
from .. import config, idempotency, sklik_guards
|
||||
from ..clients.sklik_client import SklikClient
|
||||
from ..credentials import get_sklik_token
|
||||
from ..errors import UpstreamError
|
||||
from ..logging_config import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/sklik", tags=["sklik"])
|
||||
|
||||
@@ -30,11 +47,69 @@ _REPORT_ENTITIES = {
|
||||
"banners",
|
||||
}
|
||||
|
||||
# Method-name suffixes that change data. Allowed through the generic RPC
|
||||
# passthrough by default; refused only when an operator sets
|
||||
# SKLIK_RPC_ALLOW_MUTATIONS=false to force writes through the typed endpoints.
|
||||
_MUTATING_SUFFIXES = (
|
||||
".create",
|
||||
".update",
|
||||
".remove",
|
||||
".delete",
|
||||
".restore",
|
||||
".setStatus",
|
||||
)
|
||||
|
||||
_REPORT_EXAMPLE = [
|
||||
{"dateFrom": "2026-06-01", "dateTo": "2026-06-18", "statGranularity": "daily"},
|
||||
{"statGranularity": "daily"},
|
||||
]
|
||||
|
||||
# Sensible default columns so a caller who does not know the Sklik schema still
|
||||
# gets useful rows. Callers can override with display_columns.
|
||||
_DEFAULT_COLUMNS = {
|
||||
"campaigns": [
|
||||
"id", "name", "status", "type", "dayBudget", "totalBudget",
|
||||
"totalClicks", "startDate", "endDate", "createDate", "deleted",
|
||||
],
|
||||
"groups": [
|
||||
"id", "name", "status", "maxCpc", "maxCpt", "campaign.id",
|
||||
"campaign.name", "createDate", "deleted",
|
||||
],
|
||||
"ads": [
|
||||
"id", "name", "status", "adType", "headline1", "headline2", "headline3",
|
||||
"description", "description2", "finalUrl", "group.id", "group.name",
|
||||
"campaign.id", "campaign.name", "createDate", "deleted",
|
||||
],
|
||||
}
|
||||
|
||||
_CAMPAIGN_CREATE_EXAMPLE = [
|
||||
{
|
||||
"name": "Test kampan - leto 2026",
|
||||
"type": "fulltext",
|
||||
"dayBudget": 20000,
|
||||
"totalBudget": 200000,
|
||||
}
|
||||
]
|
||||
|
||||
_GROUP_CREATE_EXAMPLE = [
|
||||
{"campaignId": 123456, "name": "Sestava A", "cpc": 300}
|
||||
]
|
||||
|
||||
_AD_CREATE_EXAMPLE = [
|
||||
{
|
||||
"groupId": 654321,
|
||||
"adType": "eta",
|
||||
"headline1": "Nadpis jedna",
|
||||
"headline2": "Nadpis dva",
|
||||
"description": "Popis inzeratu.",
|
||||
"finalUrl": "https://example.com/",
|
||||
}
|
||||
]
|
||||
|
||||
_CAMPAIGN_UPDATE_EXAMPLE = [{"id": 123456, "dayBudget": 30000, "status": "suspend"}]
|
||||
_GROUP_UPDATE_EXAMPLE = [{"id": 654321, "cpc": 450}]
|
||||
_AD_UPDATE_EXAMPLE = [{"id": 987654, "status": "suspend"}]
|
||||
|
||||
|
||||
def _optional_user_id(
|
||||
x_sklik_user_id: str | None = Header(
|
||||
@@ -55,6 +130,152 @@ def _optional_user_id(
|
||||
) from exc
|
||||
|
||||
|
||||
def _idempotency_key(
|
||||
x_idempotency_key: str | None = Header(
|
||||
default=None,
|
||||
alias="X-Idempotency-Key",
|
||||
description="Optional. Any unique string per logical operation (e.g. a "
|
||||
"UUID). When supplied, retrying with the same key returns the original "
|
||||
"result instead of creating a duplicate - recommended, because a "
|
||||
"network timeout otherwise leaves you unable to tell whether the "
|
||||
"campaign was created. Without it every call is executed as sent.",
|
||||
),
|
||||
) -> str | None:
|
||||
return (x_idempotency_key or "").strip() or None
|
||||
|
||||
|
||||
def _strip_session(payload: Any) -> Any:
|
||||
"""Remove the Sklik session from anything we hand back to the caller.
|
||||
|
||||
The session is a credential; AGENTS.md forbids returning secrets from
|
||||
ordinary endpoints. The proxy manages it internally, so the caller has no
|
||||
use for it.
|
||||
"""
|
||||
if isinstance(payload, dict) and "session" in payload:
|
||||
return {k: v for k, v in payload.items() if k != "session"}
|
||||
return payload
|
||||
|
||||
|
||||
def _id_list(raw: str | None, field: str) -> list[int] | None:
|
||||
"""Parse a comma-separated id list into ints."""
|
||||
if not raw or not raw.strip():
|
||||
return None
|
||||
try:
|
||||
return [int(part) for part in raw.split(",") if part.strip()]
|
||||
except ValueError as exc:
|
||||
raise UpstreamError(
|
||||
f"{field} must be a comma-separated list of integer ids.", status=400
|
||||
) from exc
|
||||
|
||||
|
||||
async def _mutate(
|
||||
operation: str,
|
||||
args: list[Any],
|
||||
token: str,
|
||||
user_id: int | None,
|
||||
idem_key: str | None,
|
||||
extra: dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
"""Shared write flow: (claim key) -> call Sklik -> remember the result.
|
||||
|
||||
Idempotency is applied only when the caller sent X-Idempotency-Key; without
|
||||
it the call is executed exactly as received.
|
||||
"""
|
||||
cache_key: str | None = None
|
||||
if idem_key:
|
||||
cache_key, previous = idempotency.begin(idem_key, token, operation)
|
||||
if previous is not None:
|
||||
return {**previous, "idempotentReplay": True}
|
||||
|
||||
try:
|
||||
async with SklikClient(token, user_id=user_id) as client:
|
||||
result = await client.call(operation, args)
|
||||
except Exception:
|
||||
# A failed write must stay retryable with the same key.
|
||||
if cache_key:
|
||||
idempotency.abandon(cache_key)
|
||||
raise
|
||||
|
||||
payload = dict(_strip_session(result))
|
||||
payload.update(extra or {})
|
||||
payload["idempotentReplay"] = False
|
||||
if cache_key:
|
||||
idempotency.complete(cache_key, payload)
|
||||
return payload
|
||||
|
||||
|
||||
async def _create(
|
||||
entity: str,
|
||||
items: list[Any],
|
||||
token: str,
|
||||
user_id: int | None,
|
||||
idem_key: str | None,
|
||||
) -> Any:
|
||||
prepared = sklik_guards.prepare_for_create(entity, items)
|
||||
return await _mutate(
|
||||
f"{entity}.create",
|
||||
[prepared],
|
||||
token,
|
||||
user_id,
|
||||
idem_key,
|
||||
extra={
|
||||
"createdCount": len(prepared),
|
||||
"createdStatus": sklik_guards.PAUSED_STATUS,
|
||||
"note": (
|
||||
"Created paused. Activate manually in the Sklik UI - this proxy "
|
||||
"cannot create active entities."
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _update(
|
||||
entity: str,
|
||||
items: list[Any],
|
||||
token: str,
|
||||
user_id: int | None,
|
||||
idem_key: str | None,
|
||||
) -> Any:
|
||||
prepared = sklik_guards.prepare_for_update(entity, items)
|
||||
return await _mutate(
|
||||
f"{entity}.update",
|
||||
[prepared],
|
||||
token,
|
||||
user_id,
|
||||
idem_key,
|
||||
extra={"updatedCount": len(prepared)},
|
||||
)
|
||||
|
||||
|
||||
async def _remove_or_restore(
|
||||
entity: str,
|
||||
action: str,
|
||||
ids: str | None,
|
||||
token: str,
|
||||
user_id: int | None,
|
||||
idem_key: str | None,
|
||||
) -> Any:
|
||||
parsed = sklik_guards.parse_ids(_id_list(ids, "ids") or [], entity)
|
||||
return await _mutate(
|
||||
f"{entity}.{action}",
|
||||
[parsed],
|
||||
token,
|
||||
user_id,
|
||||
idem_key,
|
||||
extra={
|
||||
f"{action}dCount": len(parsed),
|
||||
"ids": parsed,
|
||||
"note": (
|
||||
"Sklik removal is reversible - the entity is only marked as "
|
||||
"removed. Use the restore endpoint to bring it back."
|
||||
if action == "remove"
|
||||
else "Restored previously removed entities."
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# --- Session / account --------------------------------------------------------
|
||||
@router.post("/login", summary="Verify the Sklik token (client.loginByToken)")
|
||||
async def login(
|
||||
token: str = Depends(get_sklik_token),
|
||||
@@ -76,9 +297,121 @@ async def limits(
|
||||
user_id: int | None = Depends(_optional_user_id),
|
||||
) -> Any:
|
||||
async with SklikClient(token, user_id=user_id) as client:
|
||||
return await client.call("api.limits")
|
||||
return _strip_session(await client.call("api.limits"))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/write-limits",
|
||||
summary="Guard rails applied to writes (budget ceilings, forced status)",
|
||||
)
|
||||
def write_limits() -> dict:
|
||||
"""Read the ceilings this proxy enforces. No Sklik call, no credentials."""
|
||||
return sklik_guards.budget_limits()
|
||||
|
||||
|
||||
# --- Read: entity listing -----------------------------------------------------
|
||||
@router.get("/campaigns", summary="List campaigns (campaigns.list)")
|
||||
async def list_campaigns(
|
||||
ids: str | None = Query(None, description="Comma-separated campaign ids."),
|
||||
is_deleted: bool | None = Query(
|
||||
None, description="Filter deleted/undeleted. Omit to get both."
|
||||
),
|
||||
display_columns: str | None = Query(
|
||||
None,
|
||||
description="Comma-separated columns to return. Defaults to a useful "
|
||||
"subset; see https://api.sklik.cz/drak/campaigns.list.html.",
|
||||
),
|
||||
token: str = Depends(get_sklik_token),
|
||||
user_id: int | None = Depends(_optional_user_id),
|
||||
) -> Any:
|
||||
"""All pages are collected for you (offset/limit paging is handled here).
|
||||
|
||||
Note: Sklik's campaigns.list filter supports only ``ids`` and ``isDeleted``
|
||||
- there is no status filter upstream, so filter on ``status`` in the result.
|
||||
"""
|
||||
restriction: dict[str, Any] = {}
|
||||
parsed = _id_list(ids, "ids")
|
||||
if parsed:
|
||||
restriction["ids"] = parsed
|
||||
if is_deleted is not None:
|
||||
restriction["isDeleted"] = is_deleted
|
||||
|
||||
columns = (
|
||||
[c.strip() for c in display_columns.split(",") if c.strip()]
|
||||
if display_columns
|
||||
else _DEFAULT_COLUMNS["campaigns"]
|
||||
)
|
||||
async with SklikClient(token, user_id=user_id) as client:
|
||||
return _strip_session(await client.fetch_list("campaigns", restriction, columns))
|
||||
|
||||
|
||||
@router.get("/groups", summary="List groups/ad sets (groups.list)")
|
||||
async def list_groups(
|
||||
campaign_ids: str | None = Query(
|
||||
None, description="Comma-separated campaign ids to list groups from."
|
||||
),
|
||||
ids: str | None = Query(None, description="Comma-separated group ids."),
|
||||
is_deleted: bool | None = Query(None),
|
||||
display_columns: str | None = Query(None),
|
||||
token: str = Depends(get_sklik_token),
|
||||
user_id: int | None = Depends(_optional_user_id),
|
||||
) -> Any:
|
||||
restriction: dict[str, Any] = {}
|
||||
parsed = _id_list(ids, "ids")
|
||||
if parsed:
|
||||
restriction["ids"] = parsed
|
||||
campaigns = _id_list(campaign_ids, "campaign_ids")
|
||||
if campaigns:
|
||||
restriction["campaign"] = {"ids": campaigns}
|
||||
if is_deleted is not None:
|
||||
restriction["isDeleted"] = is_deleted
|
||||
|
||||
columns = (
|
||||
[c.strip() for c in display_columns.split(",") if c.strip()]
|
||||
if display_columns
|
||||
else _DEFAULT_COLUMNS["groups"]
|
||||
)
|
||||
async with SklikClient(token, user_id=user_id) as client:
|
||||
return _strip_session(await client.fetch_list("groups", restriction, columns))
|
||||
|
||||
|
||||
@router.get("/ads", summary="List ads (ads.list)")
|
||||
async def list_ads(
|
||||
campaign_ids: str | None = Query(
|
||||
None, description="Comma-separated campaign ids to list ads from."
|
||||
),
|
||||
group_ids: str | None = Query(
|
||||
None, description="Comma-separated group ids to list ads from."
|
||||
),
|
||||
ids: str | None = Query(None, description="Comma-separated ad ids."),
|
||||
is_deleted: bool | None = Query(None),
|
||||
display_columns: str | None = Query(None),
|
||||
token: str = Depends(get_sklik_token),
|
||||
user_id: int | None = Depends(_optional_user_id),
|
||||
) -> Any:
|
||||
restriction: dict[str, Any] = {}
|
||||
parsed = _id_list(ids, "ids")
|
||||
if parsed:
|
||||
restriction["ids"] = parsed
|
||||
campaigns = _id_list(campaign_ids, "campaign_ids")
|
||||
if campaigns:
|
||||
restriction["campaign"] = {"ids": campaigns}
|
||||
groups = _id_list(group_ids, "group_ids")
|
||||
if groups:
|
||||
restriction["group"] = {"ids": groups}
|
||||
if is_deleted is not None:
|
||||
restriction["isDeleted"] = is_deleted
|
||||
|
||||
columns = (
|
||||
[c.strip() for c in display_columns.split(",") if c.strip()]
|
||||
if display_columns
|
||||
else _DEFAULT_COLUMNS["ads"]
|
||||
)
|
||||
async with SklikClient(token, user_id=user_id) as client:
|
||||
return _strip_session(await client.fetch_list("ads", restriction, columns))
|
||||
|
||||
|
||||
# --- Read: statistics ---------------------------------------------------------
|
||||
@router.post(
|
||||
"/report/{entity}",
|
||||
summary="Create and read a Sklik stats report (createReport + readReport)",
|
||||
@@ -105,9 +438,183 @@ async def report(
|
||||
status=400,
|
||||
)
|
||||
async with SklikClient(token, user_id=user_id) as client:
|
||||
return await client.fetch_report(entity, body)
|
||||
return _strip_session(await client.fetch_report(entity, body))
|
||||
|
||||
|
||||
# --- Write: creation (always paused) ------------------------------------------
|
||||
@router.post("/campaigns", summary="Create campaigns — always PAUSED")
|
||||
async def create_campaigns(
|
||||
body: list[dict[str, Any]] = Body(
|
||||
...,
|
||||
examples=[_CAMPAIGN_CREATE_EXAMPLE],
|
||||
description="Array of campaign structs. 'status' is ignored and forced "
|
||||
"to 'suspend'. Budgets are in halers (100 = 1 Kc). Required per item: "
|
||||
"name, type, dayBudget. Sklik batches are all-or-nothing.",
|
||||
),
|
||||
token: str = Depends(get_sklik_token),
|
||||
user_id: int | None = Depends(_optional_user_id),
|
||||
idem_key: str | None = Depends(_idempotency_key),
|
||||
) -> Any:
|
||||
"""Create one or more campaigns in a paused state.
|
||||
|
||||
Sklik defaults ``status`` to *active*, so an omitted field would create a
|
||||
live campaign - the proxy always overrides it to ``suspend``. Activation is
|
||||
manual in the Sklik UI.
|
||||
"""
|
||||
return await _create("campaigns", body, token, user_id, idem_key)
|
||||
|
||||
|
||||
@router.post("/groups", summary="Create groups/ad sets — always PAUSED")
|
||||
async def create_groups(
|
||||
body: list[dict[str, Any]] = Body(
|
||||
...,
|
||||
examples=[_GROUP_CREATE_EXAMPLE],
|
||||
description="Array of group structs. 'status' is forced to 'suspend'. "
|
||||
"cpc is in halers. Required per item: campaignId, name, cpc.",
|
||||
),
|
||||
token: str = Depends(get_sklik_token),
|
||||
user_id: int | None = Depends(_optional_user_id),
|
||||
idem_key: str | None = Depends(_idempotency_key),
|
||||
) -> Any:
|
||||
return await _create("groups", body, token, user_id, idem_key)
|
||||
|
||||
|
||||
@router.post("/ads", summary="Create ads — always PAUSED")
|
||||
async def create_ads(
|
||||
body: list[dict[str, Any]] = Body(
|
||||
...,
|
||||
examples=[_AD_CREATE_EXAMPLE],
|
||||
description="Array of ad structs. 'status' is forced to 'suspend'. "
|
||||
"Required per item: groupId (plus headline1/headline2/description/"
|
||||
"finalUrl for the default 'eta' ad type).",
|
||||
),
|
||||
token: str = Depends(get_sklik_token),
|
||||
user_id: int | None = Depends(_optional_user_id),
|
||||
idem_key: str | None = Depends(_idempotency_key),
|
||||
) -> Any:
|
||||
return await _create("ads", body, token, user_id, idem_key)
|
||||
|
||||
|
||||
# --- Write: update ------------------------------------------------------------
|
||||
# Update is ordinary CRUD: only the supplied fields change and 'status' is NOT
|
||||
# forced, because changing it is how a campaign is paused or resumed. Set
|
||||
# SKLIK_BLOCK_ACTIVATION=true to refuse status="active" here as well.
|
||||
@router.put("/campaigns", summary="Update campaigns (partial, by id)")
|
||||
async def update_campaigns(
|
||||
body: list[dict[str, Any]] = Body(
|
||||
...,
|
||||
examples=[_CAMPAIGN_UPDATE_EXAMPLE],
|
||||
description="Array of campaign structs. 'id' is required per item; every "
|
||||
"other field is optional and only the supplied ones change. 'status' "
|
||||
"accepts 'active' or 'suspend'. Budgets are in halers. 'type' cannot be "
|
||||
"changed.",
|
||||
),
|
||||
token: str = Depends(get_sklik_token),
|
||||
user_id: int | None = Depends(_optional_user_id),
|
||||
idem_key: str | None = Depends(_idempotency_key),
|
||||
) -> Any:
|
||||
return await _update("campaigns", body, token, user_id, idem_key)
|
||||
|
||||
|
||||
@router.put("/groups", summary="Update groups/ad sets (partial, by id)")
|
||||
async def update_groups(
|
||||
body: list[dict[str, Any]] = Body(
|
||||
...,
|
||||
examples=[_GROUP_UPDATE_EXAMPLE],
|
||||
description="Array of group structs. 'id' required; name, status, cpc, "
|
||||
"cpt, maxUserDailyImpression and devicesPriceRatio are updatable.",
|
||||
),
|
||||
token: str = Depends(get_sklik_token),
|
||||
user_id: int | None = Depends(_optional_user_id),
|
||||
idem_key: str | None = Depends(_idempotency_key),
|
||||
) -> Any:
|
||||
return await _update("groups", body, token, user_id, idem_key)
|
||||
|
||||
|
||||
@router.put("/ads", summary="Update ads (partial, by id)")
|
||||
async def update_ads(
|
||||
body: list[dict[str, Any]] = Body(
|
||||
...,
|
||||
examples=[_AD_UPDATE_EXAMPLE],
|
||||
description="Array of ad structs. 'id' required. NOTE: changing the "
|
||||
"creative (headlines, description, URLs) makes Sklik delete the old ad "
|
||||
"and create a new one with a NEW id - re-read the ad afterwards.",
|
||||
),
|
||||
token: str = Depends(get_sklik_token),
|
||||
user_id: int | None = Depends(_optional_user_id),
|
||||
idem_key: str | None = Depends(_idempotency_key),
|
||||
) -> Any:
|
||||
return await _update("ads", body, token, user_id, idem_key)
|
||||
|
||||
|
||||
# --- Write: remove / restore --------------------------------------------------
|
||||
# Sklik removal is a soft delete ("marked as removed"), so every remove has a
|
||||
# matching restore.
|
||||
@router.delete("/campaigns", summary="Remove campaigns (reversible)")
|
||||
async def remove_campaigns(
|
||||
ids: str = Query(..., description="Comma-separated campaign ids to remove."),
|
||||
token: str = Depends(get_sklik_token),
|
||||
user_id: int | None = Depends(_optional_user_id),
|
||||
idem_key: str | None = Depends(_idempotency_key),
|
||||
) -> Any:
|
||||
return await _remove_or_restore(
|
||||
"campaigns", "remove", ids, token, user_id, idem_key
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/groups", summary="Remove groups/ad sets (reversible)")
|
||||
async def remove_groups(
|
||||
ids: str = Query(..., description="Comma-separated group ids to remove."),
|
||||
token: str = Depends(get_sklik_token),
|
||||
user_id: int | None = Depends(_optional_user_id),
|
||||
idem_key: str | None = Depends(_idempotency_key),
|
||||
) -> Any:
|
||||
return await _remove_or_restore("groups", "remove", ids, token, user_id, idem_key)
|
||||
|
||||
|
||||
@router.delete("/ads", summary="Remove ads (reversible)")
|
||||
async def remove_ads(
|
||||
ids: str = Query(..., description="Comma-separated ad ids to remove."),
|
||||
token: str = Depends(get_sklik_token),
|
||||
user_id: int | None = Depends(_optional_user_id),
|
||||
idem_key: str | None = Depends(_idempotency_key),
|
||||
) -> Any:
|
||||
return await _remove_or_restore("ads", "remove", ids, token, user_id, idem_key)
|
||||
|
||||
|
||||
@router.post("/campaigns/restore", summary="Restore removed campaigns")
|
||||
async def restore_campaigns(
|
||||
ids: str = Query(..., description="Comma-separated campaign ids to restore."),
|
||||
token: str = Depends(get_sklik_token),
|
||||
user_id: int | None = Depends(_optional_user_id),
|
||||
idem_key: str | None = Depends(_idempotency_key),
|
||||
) -> Any:
|
||||
return await _remove_or_restore(
|
||||
"campaigns", "restore", ids, token, user_id, idem_key
|
||||
)
|
||||
|
||||
|
||||
@router.post("/groups/restore", summary="Restore removed groups/ad sets")
|
||||
async def restore_groups(
|
||||
ids: str = Query(..., description="Comma-separated group ids to restore."),
|
||||
token: str = Depends(get_sklik_token),
|
||||
user_id: int | None = Depends(_optional_user_id),
|
||||
idem_key: str | None = Depends(_idempotency_key),
|
||||
) -> Any:
|
||||
return await _remove_or_restore("groups", "restore", ids, token, user_id, idem_key)
|
||||
|
||||
|
||||
@router.post("/ads/restore", summary="Restore removed ads")
|
||||
async def restore_ads(
|
||||
ids: str = Query(..., description="Comma-separated ad ids to restore."),
|
||||
token: str = Depends(get_sklik_token),
|
||||
user_id: int | None = Depends(_optional_user_id),
|
||||
idem_key: str | None = Depends(_idempotency_key),
|
||||
) -> Any:
|
||||
return await _remove_or_restore("ads", "restore", ids, token, user_id, idem_key)
|
||||
|
||||
|
||||
# --- Generic passthrough ------------------------------------------------------
|
||||
@router.post(
|
||||
"/rpc/{method}",
|
||||
summary="Generic authenticated Sklik call (any method)",
|
||||
@@ -116,7 +623,9 @@ async def rpc(
|
||||
method: str = Path(
|
||||
...,
|
||||
description="Sklik method name, e.g. campaigns.list, groups.list, "
|
||||
"ads.list, api.limits. (client.loginByToken is managed by the proxy.)",
|
||||
"ads.list, api.limits. Mutating methods work too, but bypass the typed "
|
||||
"write endpoints' paused-status and budget guard rails. "
|
||||
"(client.loginByToken is managed by the proxy.)",
|
||||
),
|
||||
args: list[Any] = Body(
|
||||
default=[],
|
||||
@@ -127,5 +636,13 @@ async def rpc(
|
||||
token: str = Depends(get_sklik_token),
|
||||
user_id: int | None = Depends(_optional_user_id),
|
||||
) -> Any:
|
||||
if not config.SKLIK_RPC_ALLOW_MUTATIONS and method.endswith(_MUTATING_SUFFIXES):
|
||||
raise UpstreamError(
|
||||
f"'{method}' modifies data and is not allowed through the generic "
|
||||
"RPC passthrough, which has no budget or idempotency guard rails. "
|
||||
"Use POST /sklik/campaigns, /sklik/groups or /sklik/ads instead. "
|
||||
"(An operator can override this with SKLIK_RPC_ALLOW_MUTATIONS.)",
|
||||
status=403,
|
||||
)
|
||||
async with SklikClient(token, user_id=user_id) as client:
|
||||
return await client.call(method, args)
|
||||
return _strip_session(await client.call(method, args))
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
"""Guard rails for Sklik write operations.
|
||||
|
||||
1. **Everything is created paused.** This is a requirement of the integration:
|
||||
entities are created in a paused state and activation is always manual.
|
||||
Sklik's ``status`` defaults to ``active``, so an omitted field would silently
|
||||
produce a *live* campaign - the proxy therefore forces
|
||||
``status: "suspend"`` on every created entity. This one is not configurable;
|
||||
it is the point of the endpoint.
|
||||
2. **Budget ceilings - OPT-IN, disabled by default.** ``dayBudget`` /
|
||||
``totalBudget`` / ``cpc`` are only checked when the corresponding
|
||||
``SKLIK_MAX_*`` config value is non-zero. Out of the box the proxy passes the
|
||||
caller's numbers through untouched; approval lives on the caller's side.
|
||||
Enabling a ceiling catches the misplaced decimal point (Sklik works in
|
||||
halers, so "50000" meant as korunas is 500 Kc) before it reaches Sklik.
|
||||
3. **Idempotency - opt-in per request**, see ``app.idempotency``.
|
||||
|
||||
Basic shape validation (required fields, integer amounts) always applies: it
|
||||
turns a generic upstream rejection into a message that says what is wrong.
|
||||
|
||||
Amounts are in HALERS throughout (100 halers = 1 Kc), matching the Sklik API.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from . import config
|
||||
from .errors import UpstreamError
|
||||
from .logging_config import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
#: Sklik's paused state. The alternative is "active".
|
||||
PAUSED_STATUS = "suspend"
|
||||
ACTIVE_STATUS = "active"
|
||||
_VALID_STATUSES = (ACTIVE_STATUS, PAUSED_STATUS)
|
||||
|
||||
#: Money fields per entity: field name -> (config ceiling, human label).
|
||||
_MONEY_FIELDS: dict[str, dict[str, tuple[int, str]]] = {
|
||||
"campaigns": {
|
||||
"dayBudget": (config.SKLIK_MAX_DAY_BUDGET_HALERS, "daily budget"),
|
||||
"totalBudget": (config.SKLIK_MAX_TOTAL_BUDGET_HALERS, "total budget"),
|
||||
},
|
||||
"groups": {
|
||||
"cpc": (config.SKLIK_MAX_CPC_HALERS, "max CPC"),
|
||||
"cpt": (config.SKLIK_MAX_CPC_HALERS, "max CPT"),
|
||||
},
|
||||
"ads": {},
|
||||
}
|
||||
|
||||
#: Fields Sklik requires on create, checked here so the caller gets a clear
|
||||
#: message instead of a generic upstream rejection.
|
||||
_REQUIRED_FIELDS: dict[str, tuple[str, ...]] = {
|
||||
"campaigns": ("name", "dayBudget", "type"),
|
||||
"groups": ("campaignId", "name", "cpc"),
|
||||
"ads": ("groupId",),
|
||||
}
|
||||
|
||||
|
||||
def _halers_to_czk(value: int) -> str:
|
||||
return f"{value / 100:.2f} Kc"
|
||||
|
||||
|
||||
def _check_money(entity: str, index: int, clean: dict) -> None:
|
||||
"""Reject amounts above a configured ceiling. 0 in config = no ceiling."""
|
||||
for field, (ceiling, label) in _MONEY_FIELDS.get(entity, {}).items():
|
||||
value = clean.get(field)
|
||||
if value is None:
|
||||
continue
|
||||
if not isinstance(value, int) or isinstance(value, bool):
|
||||
raise UpstreamError(
|
||||
f"Item {index}: '{field}' must be an integer amount in halers "
|
||||
"(100 halers = 1 Kc).",
|
||||
status=400,
|
||||
)
|
||||
if value < 0:
|
||||
raise UpstreamError(
|
||||
f"Item {index}: '{field}' must not be negative.", status=400
|
||||
)
|
||||
if ceiling > 0 and value > ceiling:
|
||||
raise UpstreamError(
|
||||
f"Item {index}: {label} {_halers_to_czk(value)} exceeds the "
|
||||
f"configured ceiling {_halers_to_czk(ceiling)}. Amounts are in "
|
||||
"halers (100 = 1 Kc) - check for a misplaced decimal point, or "
|
||||
"raise the limit in the service configuration.",
|
||||
status=400,
|
||||
)
|
||||
|
||||
|
||||
def prepare_for_create(entity: str, items: list[Any]) -> list[dict]:
|
||||
"""Validate and normalize a batch of entities for ``{entity}.create``.
|
||||
|
||||
Returns a new list; the caller's input is never mutated. Raises
|
||||
``UpstreamError`` (400) on the first problem, naming the offending item's
|
||||
index so a batch failure is diagnosable.
|
||||
"""
|
||||
if not isinstance(items, list) or not items:
|
||||
raise UpstreamError(
|
||||
f"Provide a non-empty JSON array of {entity} to create.", status=400
|
||||
)
|
||||
|
||||
prepared: list[dict] = []
|
||||
for index, item in enumerate(items):
|
||||
if not isinstance(item, dict):
|
||||
raise UpstreamError(
|
||||
f"Item {index} must be a JSON object describing one "
|
||||
f"{entity[:-1]}.",
|
||||
status=400,
|
||||
)
|
||||
|
||||
for field in _REQUIRED_FIELDS.get(entity, ()):
|
||||
if item.get(field) in (None, ""):
|
||||
raise UpstreamError(
|
||||
f"Item {index}: '{field}' is required when creating "
|
||||
f"{entity}.",
|
||||
status=400,
|
||||
)
|
||||
|
||||
clean = dict(item)
|
||||
|
||||
# Rule 1: always paused, never negotiable.
|
||||
requested = clean.get("status")
|
||||
if requested is not None and requested != PAUSED_STATUS:
|
||||
# Not an error - we simply override it - but it must be visible.
|
||||
logger.warning(
|
||||
"Item %d requested status=%r for %s; forcing %r. This proxy "
|
||||
"cannot create active entities.",
|
||||
index,
|
||||
requested,
|
||||
entity,
|
||||
PAUSED_STATUS,
|
||||
)
|
||||
clean["status"] = PAUSED_STATUS
|
||||
|
||||
# Rule 2: money ceilings - only where one is configured (0 = disabled).
|
||||
_check_money(entity, index, clean)
|
||||
|
||||
prepared.append(clean)
|
||||
|
||||
logger.info(
|
||||
"Prepared %d %s for creation (all forced to status=%s).",
|
||||
len(prepared),
|
||||
entity,
|
||||
PAUSED_STATUS,
|
||||
)
|
||||
return prepared
|
||||
|
||||
|
||||
def prepare_for_update(entity: str, items: list[Any]) -> list[dict]:
|
||||
"""Validate a batch of entities for ``{entity}.update``.
|
||||
|
||||
Unlike create, update does NOT force a status: changing ``status`` is how a
|
||||
campaign is paused or resumed, and this is ordinary CRUD. Only two checks
|
||||
apply - the money ceilings (when configured) and, if the operator enabled
|
||||
``SKLIK_BLOCK_ACTIVATION``, a refusal to set ``status: "active"`` so that
|
||||
starting a campaign stays a manual action.
|
||||
|
||||
``id`` is required on every item; everything else is optional and only the
|
||||
supplied fields are changed upstream.
|
||||
"""
|
||||
if not isinstance(items, list) or not items:
|
||||
raise UpstreamError(
|
||||
f"Provide a non-empty JSON array of {entity} to update.", status=400
|
||||
)
|
||||
|
||||
prepared: list[dict] = []
|
||||
for index, item in enumerate(items):
|
||||
if not isinstance(item, dict):
|
||||
raise UpstreamError(
|
||||
f"Item {index} must be a JSON object with an 'id' and the "
|
||||
"fields to change.",
|
||||
status=400,
|
||||
)
|
||||
if item.get("id") in (None, ""):
|
||||
raise UpstreamError(
|
||||
f"Item {index}: 'id' is required when updating {entity}.",
|
||||
status=400,
|
||||
)
|
||||
|
||||
clean = dict(item)
|
||||
status = clean.get("status")
|
||||
if status is not None:
|
||||
if status not in _VALID_STATUSES:
|
||||
raise UpstreamError(
|
||||
f"Item {index}: status must be one of "
|
||||
+ ", ".join(_VALID_STATUSES)
|
||||
+ f" (got {status!r}).",
|
||||
status=400,
|
||||
)
|
||||
if status == ACTIVE_STATUS and config.SKLIK_BLOCK_ACTIVATION:
|
||||
raise UpstreamError(
|
||||
f"Item {index}: activating entities through the API is "
|
||||
"disabled (SKLIK_BLOCK_ACTIVATION). Start the campaign "
|
||||
"manually in the Sklik UI.",
|
||||
status=403,
|
||||
)
|
||||
|
||||
_check_money(entity, index, clean)
|
||||
prepared.append(clean)
|
||||
|
||||
activating = sum(1 for i in prepared if i.get("status") == ACTIVE_STATUS)
|
||||
if activating:
|
||||
# Activation starts spending - always visible in the log.
|
||||
logger.warning(
|
||||
"Updating %d %s to status=active (spending may start).",
|
||||
activating,
|
||||
entity,
|
||||
)
|
||||
logger.info("Prepared %d %s for update.", len(prepared), entity)
|
||||
return prepared
|
||||
|
||||
|
||||
def parse_ids(raw_ids: list[Any], entity: str) -> list[int]:
|
||||
"""Validate a list of entity ids for remove/restore."""
|
||||
if not isinstance(raw_ids, list) or not raw_ids:
|
||||
raise UpstreamError(
|
||||
f"Provide a non-empty list of {entity} ids.", status=400
|
||||
)
|
||||
ids: list[int] = []
|
||||
for index, value in enumerate(raw_ids):
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise UpstreamError(
|
||||
f"Id at position {index} must be an integer (got {value!r}).",
|
||||
status=400,
|
||||
)
|
||||
ids.append(value)
|
||||
return ids
|
||||
|
||||
|
||||
def _ceiling(value: int) -> int | None:
|
||||
"""0 in config means "no ceiling"; report that as null, not as zero."""
|
||||
return value if value > 0 else None
|
||||
|
||||
|
||||
def budget_limits() -> dict[str, Any]:
|
||||
"""The active ceilings, so callers can read them instead of guessing."""
|
||||
return {
|
||||
"currency": "CZK",
|
||||
"unit": "haler (100 = 1 Kc)",
|
||||
"maxDayBudget": _ceiling(config.SKLIK_MAX_DAY_BUDGET_HALERS),
|
||||
"maxTotalBudget": _ceiling(config.SKLIK_MAX_TOTAL_BUDGET_HALERS),
|
||||
"maxCpc": _ceiling(config.SKLIK_MAX_CPC_HALERS),
|
||||
"budgetLimitsEnforced": any(
|
||||
v > 0
|
||||
for v in (
|
||||
config.SKLIK_MAX_DAY_BUDGET_HALERS,
|
||||
config.SKLIK_MAX_TOTAL_BUDGET_HALERS,
|
||||
config.SKLIK_MAX_CPC_HALERS,
|
||||
)
|
||||
),
|
||||
"createdStatus": PAUSED_STATUS,
|
||||
"activationBlocked": config.SKLIK_BLOCK_ACTIVATION,
|
||||
"note": (
|
||||
"Everything created through this proxy is paused. null ceilings "
|
||||
"mean no budget limit is enforced here - configure SKLIK_MAX_* to "
|
||||
"enable one. Update can set status=active unless "
|
||||
"activationBlocked is true."
|
||||
),
|
||||
}
|
||||
@@ -36,9 +36,11 @@ app/
|
||||
logging_config.py get_logger(); secrets are never logged
|
||||
errors.py MissingCredentialsError, UpstreamError + handlers
|
||||
credentials.py X- header dependencies (GA / GSC / Ads / Sklik)
|
||||
idempotency.py in-memory idempotency store for writes (opt-in per request)
|
||||
sklik_guards.py write guard rails: forced paused status, optional budget caps
|
||||
clients/
|
||||
google.py shared Google client: Bearer/SA token minting + requests
|
||||
sklik_client.py Sklik JSON-RPC client (login + session + report paging)
|
||||
sklik_client.py Sklik JSON-RPC client (login + session + list/report paging)
|
||||
routers/
|
||||
meta.py /health, /version
|
||||
ga_data.py /ga/data/... (Google Analytics Data)
|
||||
@@ -64,6 +66,23 @@ routes are unprefixed (Caddy `handle_path` strips the prefix).
|
||||
| Google Ads | `X-GAds-Developer-Token` (req) + `X-GAds-Access-Token` **or** `X-GAds-Credentials` (+ `X-GAds-Login-Customer-Id`, `X-GAds-Quota-Project`) | Same OAuth (scope `adwords`) plus `developer-token` / `login-customer-id` headers forwarded upstream. |
|
||||
| Sklik | `X-Sklik-Token` (+ `X-Sklik-User-Id`) | `client.loginByToken` per request → session injected into the call. |
|
||||
|
||||
## Writes
|
||||
|
||||
**Sklik is the only upstream with write endpoints** — full CRUD over campaigns,
|
||||
groups and ads (create / update / remove / restore). The pattern established
|
||||
there — create paused, optional budget ceilings, optional idempotency key — is
|
||||
the template for any future write support (Google Ads mutates, Meta campaign
|
||||
management in the sibling `meta` service).
|
||||
|
||||
The one non-negotiable rule: **entities are always created paused**. Sklik
|
||||
defaults `status` to `active`, so this must be forced server-side. Update does
|
||||
*not* force a status (that is how you pause or resume a campaign), but an
|
||||
operator can refuse activation entirely with `SKLIK_BLOCK_ACTIVATION`. Budget
|
||||
ceilings and idempotency are opt-in, since the approval flow lives on the
|
||||
caller's side. Removal is Sklik's own soft delete and is reversible.
|
||||
|
||||
See `sklik.md` for details.
|
||||
|
||||
## Deliberately not wired
|
||||
|
||||
- **Write operations** across the Google services: GA Admin (create/update
|
||||
@@ -71,6 +90,9 @@ routes are unprefixed (Caddy `handle_path` strips the prefix).
|
||||
sites), Google Ads mutates (create/update campaigns etc.). All requested
|
||||
scopes are read-only; add the read-write scope + endpoints if management is
|
||||
needed later. Google Ads exposes reporting (GAQL) only for now.
|
||||
- **Sklik keyword / sitelink / product-set management.** Only campaigns, groups
|
||||
and ads have typed CRUD; the rest of the Sklik API remains reachable through
|
||||
`POST /sklik/rpc/{method}` without guard rails.
|
||||
- **Sklik header-credential encryption.** Same deferral as `idoklad`/`csob`:
|
||||
header values are plaintext over TLS for now.
|
||||
- **Sklik session reuse across requests** — the chosen model logs in per
|
||||
|
||||
+175
-1
@@ -45,9 +45,172 @@ access denied, bad arguments) are surfaced as `upstream_error` with the Sklik
|
||||
| --- | --- | --- |
|
||||
| POST | `/sklik/login` | Verify the token. Returns `{valid, status, statusMessage}` (no session). |
|
||||
| GET | `/sklik/limits` | `api.limits` — quotas and the `statsDataLimit`. |
|
||||
| GET | `/sklik/write-limits` | Guard rails applied to writes. No credentials needed. |
|
||||
| GET | `/sklik/campaigns` | `campaigns.list`, all pages collected. |
|
||||
| GET | `/sklik/groups` | `groups.list`, filterable by `campaign_ids`. |
|
||||
| GET | `/sklik/ads` | `ads.list`, filterable by `campaign_ids` / `group_ids`. |
|
||||
| POST | `/sklik/campaigns` `/sklik/groups` `/sklik/ads` | **Create — always paused.** |
|
||||
| PUT | `/sklik/campaigns` `/sklik/groups` `/sklik/ads` | **Update** by id (partial). |
|
||||
| DELETE | `/sklik/campaigns` `/sklik/groups` `/sklik/ads` | **Remove** (`?ids=1,2,3`) — reversible. |
|
||||
| POST | `/sklik/{entity}/restore` | **Restore** removed entities (`?ids=1,2,3`). |
|
||||
| POST | `/sklik/report/{entity}` | `createReport` + paged `readReport` for an entity. |
|
||||
| POST | `/sklik/rpc/{method}` | Generic authenticated call to any method. |
|
||||
|
||||
> The proxy never returns the Sklik `session` — it is a credential, and
|
||||
> AGENTS.md forbids returning secrets from ordinary endpoints. The session is
|
||||
> managed internally and callers have no use for it.
|
||||
|
||||
### Listing entities
|
||||
|
||||
`GET /sklik/campaigns`, `/sklik/groups`, `/sklik/ads` wrap `{entity}.list` and
|
||||
page through the whole result set (offset/limit, `SKLIK_LIST_PAGE_LIMIT` rows
|
||||
per page, capped by `SKLIK_LIST_MAX_PAGES`):
|
||||
|
||||
```json
|
||||
{ "totalCount": 42, "returnedCount": 42, "truncated": false, "campaigns": [ ... ] }
|
||||
```
|
||||
|
||||
Query parameters:
|
||||
|
||||
| Parameter | Endpoints | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `ids` | all | Comma-separated ids of the entity itself. |
|
||||
| `campaign_ids` | groups, ads | Restrict to these campaigns. |
|
||||
| `group_ids` | ads | Restrict to these groups. |
|
||||
| `is_deleted` | all | `true`/`false`. Omit to get both. |
|
||||
| `display_columns` | all | Comma-separated columns; defaults to a useful subset. |
|
||||
|
||||
> Sklik's `campaigns.list` filter supports only `ids` and `isDeleted` — there is
|
||||
> **no status filter upstream**, so filter on `status` in the returned rows.
|
||||
> `groups.list` and `ads.list` do support parent filters (`campaign.ids`,
|
||||
> `group.ids`), which is what `campaign_ids` / `group_ids` map to.
|
||||
|
||||
## Writes — full CRUD over campaigns / groups / ads
|
||||
|
||||
| Operation | Endpoint | Upstream | Body / params |
|
||||
| --- | --- | --- | --- |
|
||||
| Create | `POST /sklik/{entity}` | `{entity}.create` | JSON array of structs |
|
||||
| Update | `PUT /sklik/{entity}` | `{entity}.update` | JSON array of structs, `id` required |
|
||||
| Remove | `DELETE /sklik/{entity}?ids=1,2` | `{entity}.remove` | ids in the query |
|
||||
| Restore | `POST /sklik/{entity}/restore?ids=1,2` | `{entity}.restore` | ids in the query |
|
||||
|
||||
`{entity}` ∈ `campaigns`, `groups`, `ads`. The structs are exactly the ones Sklik
|
||||
documents ([campaigns.create](https://api.sklik.cz/drak/campaigns.create.html),
|
||||
[campaigns.update](https://api.sklik.cz/drak/campaigns.update.html), and the
|
||||
`groups.*` / `ads.*` equivalents). Sklik batches are **all-or-nothing**: if one
|
||||
item fails, nothing is applied.
|
||||
|
||||
### Create — everything is paused, not configurable
|
||||
|
||||
Sklik's `status` field **defaults to `active`**, so an omitted status would
|
||||
create a *live, spending* campaign. The proxy therefore forces
|
||||
`status: "suspend"` on every created entity and ignores any other value you
|
||||
send (the override is logged).
|
||||
|
||||
### Update — ordinary CRUD, status is not forced
|
||||
|
||||
`PUT` changes only the fields you send; `id` is required per item. Unlike
|
||||
create, `status` is passed through as given — setting `suspend` is how you pause
|
||||
a running campaign and `active` is how you resume one, so forcing a value here
|
||||
would break half the use cases.
|
||||
|
||||
That does mean **update can start spending**. If you want activation to stay a
|
||||
manual action in the Sklik UI, set `SKLIK_BLOCK_ACTIVATION=true`: `status:
|
||||
"active"` is then refused with `403` while pausing still works. Default is
|
||||
`false` (both directions allowed). Every activation is logged at WARNING either
|
||||
way.
|
||||
|
||||
> **Ads: changing the creative replaces the ad.** Sklik cannot edit an existing
|
||||
> ad's headlines, description or URLs — it deletes the old ad and creates a new
|
||||
> one, so the ad gets a **new id**. Re-read the group's ads after such an
|
||||
> update. Changing only `status` keeps the id.
|
||||
|
||||
`type` cannot be changed on a campaign.
|
||||
|
||||
### Remove and restore — reversible
|
||||
|
||||
Sklik's removal is a soft delete: "the campaign is not really removed; it is
|
||||
only marked as removed". Every `DELETE` therefore has a matching restore
|
||||
endpoint, and removed entities still show up in listings unless you filter with
|
||||
`is_deleted=false`.
|
||||
|
||||
```bash
|
||||
curl -X DELETE ".../sklik/campaigns?ids=123456" -H "X-Sklik-Token: <TOKEN>"
|
||||
curl -X POST ".../sklik/campaigns/restore?ids=123456" -H "X-Sklik-Token: <TOKEN>"
|
||||
```
|
||||
|
||||
### Optional guard rails
|
||||
|
||||
Both are **off by default** — the proxy does not second-guess your numbers
|
||||
unless you ask it to:
|
||||
|
||||
| Guard | How to enable | Effect |
|
||||
| --- | --- | --- |
|
||||
| Budget ceilings | Set `SKLIK_MAX_DAY_BUDGET_HALERS`, `SKLIK_MAX_TOTAL_BUDGET_HALERS`, `SKLIK_MAX_CPC_HALERS` to a non-zero value | A create **or update** above the ceiling is rejected with `400` before Sklik is called. |
|
||||
| Idempotency | Send an `X-Idempotency-Key` header | A retry with the same key returns the original result instead of repeating the write. Works on every write endpoint. |
|
||||
| No activation via API | Set `SKLIK_BLOCK_ACTIVATION=true` | `PUT` refuses `status: "active"` with `403`; pausing still works. |
|
||||
|
||||
Amounts are in **halers** (100 halers = 1 Kč), matching the Sklik API — a
|
||||
ceiling mainly protects against a misplaced decimal point. `GET
|
||||
/sklik/write-limits` reports what is currently enforced (`null` = no limit).
|
||||
|
||||
Basic shape validation always applies (required fields present, money fields
|
||||
integer and non-negative), so you get a clear message instead of a generic
|
||||
upstream rejection.
|
||||
|
||||
### Idempotency
|
||||
|
||||
Recommended for writes: if a create times out on the network you cannot tell
|
||||
whether the campaign was created, and a blind retry creates a second one.
|
||||
|
||||
- Send `X-Idempotency-Key: <unique string per logical operation>` (e.g. a UUID).
|
||||
- A retry with the same key returns the stored result plus
|
||||
`"idempotentReplay": true` — Sklik is not called again.
|
||||
- A **failed** create releases the key, so you can retry it.
|
||||
- A concurrent duplicate (same key still in flight) gets `409`.
|
||||
|
||||
Limitations, stated plainly: the store is **in-memory and per-container**. It
|
||||
does not survive a restart and is not shared between replicas, so with more than
|
||||
one container a retry can land somewhere that has never seen the key. It removes
|
||||
the common failure (an immediate retry after a timeout); it is not a distributed
|
||||
guarantee. Moving it to Redis should be a conscious decision, not a surprise.
|
||||
|
||||
### Example — create a paused campaign
|
||||
|
||||
```bash
|
||||
curl -X POST "https://services.csbot.cz/apps/analytics/sklik/campaigns" \
|
||||
-H "X-Sklik-Token: <TOKEN>" \
|
||||
-H "X-Idempotency-Key: 8f3a1c02-0f1e-4c3a-9d6b-2b7e5f0a1c44" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '[{"name":"Léto 2026","type":"fulltext","dayBudget":20000,"totalBudget":200000}]'
|
||||
```
|
||||
|
||||
`dayBudget: 20000` = 200 Kč/day. Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": 200,
|
||||
"statusMessage": "OK",
|
||||
"campaignIds": [123456],
|
||||
"createdCount": 1,
|
||||
"createdStatus": "suspend",
|
||||
"idempotentReplay": false,
|
||||
"note": "Created paused. Activate manually in the Sklik UI ..."
|
||||
}
|
||||
```
|
||||
|
||||
Then a group and an ad in it:
|
||||
|
||||
```bash
|
||||
curl -X POST ".../sklik/groups" -H "X-Sklik-Token: <TOKEN>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '[{"campaignId":123456,"name":"Sestava A","cpc":300}]'
|
||||
|
||||
curl -X POST ".../sklik/ads" -H "X-Sklik-Token: <TOKEN>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '[{"groupId":654321,"adType":"eta","headline1":"Nadpis jedna","headline2":"Nadpis dva","description":"Popis inzerátu.","finalUrl":"https://example.com/"}]'
|
||||
```
|
||||
|
||||
### Report helper
|
||||
|
||||
`entity` ∈ `campaigns, groups, ads, keywords, queries, sitelinks, productSets,
|
||||
@@ -71,7 +234,18 @@ Example body for `POST /sklik/report/campaigns`:
|
||||
### Generic RPC
|
||||
|
||||
`POST /sklik/rpc/{method}` with a JSON-array body of the arguments **after** the
|
||||
session struct (which the proxy injects). Examples:
|
||||
session struct (which the proxy injects). Reaches **any** method, including
|
||||
mutating ones — that is the long-standing behaviour and it is unchanged.
|
||||
|
||||
> Calls made this way bypass the typed write endpoints' guard rails: nothing
|
||||
> forces `status: "suspend"`, no budget ceiling applies and there is no
|
||||
> idempotency. For creating campaigns prefer `POST /sklik/campaigns`.
|
||||
> An operator who wants to enforce that can set
|
||||
> `SKLIK_RPC_ALLOW_MUTATIONS=false`, which makes this endpoint refuse
|
||||
> `.create` / `.update` / `.remove` / `.delete` / `.restore` / `.setStatus`
|
||||
> with `403`. Default is `true` (everything allowed).
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
# List campaigns
|
||||
|
||||
Reference in New Issue
Block a user