diff --git a/README.md b/README.md index c074257..65b15be 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/app/clients/sklik_client.py b/app/clients/sklik_client.py index cf945ae..54b1f91 100644 --- a/app/clients/sklik_client.py +++ b/app/clients/sklik_client.py @@ -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: diff --git a/app/config.py b/app/config.py index 51b0e4b..69fe393 100644 --- a/app/config.py +++ b/app/config.py @@ -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")) diff --git a/app/idempotency.py b/app/idempotency.py new file mode 100644 index 0000000..fc51b55 --- /dev/null +++ b/app/idempotency.py @@ -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] diff --git a/app/main.py b/app/main.py index 5190649..690c543 100644 --- a/app/main.py +++ b/app/main.py @@ -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 `. --- +## ⚠️ 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() diff --git a/app/routers/sklik.py b/app/routers/sklik.py index 056ff65..7132c7e 100644 --- a/app/routers/sklik.py +++ b/app/routers/sklik.py @@ -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)) diff --git a/app/sklik_guards.py b/app/sklik_guards.py new file mode 100644 index 0000000..6cb10b1 --- /dev/null +++ b/app/sklik_guards.py @@ -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." + ), + } diff --git a/documentation/overview.md b/documentation/overview.md index b8bdb9d..3e85044 100644 --- a/documentation/overview.md +++ b/documentation/overview.md @@ -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 diff --git a/documentation/sklik.md b/documentation/sklik.md index 93334c5..e6a30f2 100644 --- a/documentation/sklik.md +++ b/documentation/sklik.md @@ -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: " +curl -X POST ".../sklik/campaigns/restore?ids=123456" -H "X-Sklik-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: ` (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: " \ + -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: " \ + -H "Content-Type: application/json" \ + -d '[{"campaignId":123456,"name":"Sestava A","cpc":300}]' + +curl -X POST ".../sklik/ads" -H "X-Sklik-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