sklik rozsireni na typove metody

This commit is contained in:
JiriUhlir
2026-07-20 08:29:23 +02:00
parent 28b29b8331
commit 0ed3e11a4d
9 changed files with 1255 additions and 10 deletions
+22 -2
View File
@@ -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.
+52
View File
@@ -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:
+41
View File
@@ -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"))
+124
View File
@@ -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
View File
@@ -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
View File
@@ -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))
+258
View File
@@ -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."
),
}
+23 -1
View File
@@ -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
View File
@@ -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