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 | POST | `/googleads/customers/{id}/search`, `/googleads/customers/{id}/searchStream` |
| Google Ads | GET | `/googleads/customers:listAccessibleCustomers` | | Google Ads | GET | `/googleads/customers:listAccessibleCustomers` |
| Sklik | POST | `/sklik/login`, `/sklik/report/{entity}`, `/sklik/rpc/{method}` | | 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) ## 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-Token` | yes | Sklik API token from account settings. |
| `X-Sklik-User-Id` | no | Managed account id for agency/MCC access. | | `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 Where to obtain each credential is described at the top of `/docs` (Swagger) and
in [documentation/](documentation/). 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`, `GA_DATA_BASE_URL`, `GA_ADMIN_BASE_URL`, `GA_SCOPE`, `GSC_DATA_BASE_URL`,
`GSC_INSPECT_BASE_URL`, `GSC_SCOPE`, `GOOGLE_ADS_BASE_URL`, `GSC_INSPECT_BASE_URL`, `GSC_SCOPE`, `GOOGLE_ADS_BASE_URL`,
`GOOGLE_ADS_API_VERSION`, `GOOGLE_ADS_SCOPE`, `SKLIK_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. See [documentation/](documentation/) for per-integration detail.
+52
View File
@@ -133,6 +133,58 @@ class SklikClient:
full_args = [self._user_struct()] + list(args or []) full_args = [self._user_struct()] + list(args or [])
return await self._call(method, full_args) 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( async def fetch_report(
self, entity: str, report_args: list[Any] self, entity: str, report_args: list[Any]
) -> dict: ) -> dict:
+41
View File
@@ -60,6 +60,47 @@ GOOGLE_ADS_SCOPE = os.getenv(
# HTTP body is a JSON array of positional arguments. # HTTP body is a JSON array of positional arguments.
SKLIK_BASE_URL = os.getenv("SKLIK_BASE_URL", "https://api.sklik.cz/drak/json/v5") 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 --------------------------------------------------------------------- # --- HTTP ---------------------------------------------------------------------
# Upstream request timeout in seconds. # Upstream request timeout in seconds.
HTTP_TIMEOUT_SECONDS = float(os.getenv("HTTP_TIMEOUT_SECONDS", "60")) 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 | | Google Ads | `X-GAds-Login-Customer-Id`, `X-GAds-Quota-Project` | ne |
| Sklik | `X-Sklik-Token` | ano | | Sklik | `X-Sklik-Token` | ano |
| Sklik | `X-Sklik-User-Id` | ne (jen pro agenturní/MCC přístup) | | 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 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 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. Podrobnosti k jednotlivým endpointům jsou v `documentation/` v repozitáři.
""".strip() """.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 performs the requested call, injecting the session for you. See
``app.clients.sklik_client`` and https://api.sklik.cz/drak/ for methods. ``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 __future__ import annotations
from typing import Any 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 ..clients.sklik_client import SklikClient
from ..credentials import get_sklik_token from ..credentials import get_sklik_token
from ..errors import UpstreamError from ..errors import UpstreamError
from ..logging_config import get_logger
logger = get_logger(__name__)
router = APIRouter(prefix="/sklik", tags=["sklik"]) router = APIRouter(prefix="/sklik", tags=["sklik"])
@@ -30,11 +47,69 @@ _REPORT_ENTITIES = {
"banners", "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 = [ _REPORT_EXAMPLE = [
{"dateFrom": "2026-06-01", "dateTo": "2026-06-18", "statGranularity": "daily"}, {"dateFrom": "2026-06-01", "dateTo": "2026-06-18", "statGranularity": "daily"},
{"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( def _optional_user_id(
x_sklik_user_id: str | None = Header( x_sklik_user_id: str | None = Header(
@@ -55,6 +130,152 @@ def _optional_user_id(
) from exc ) 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)") @router.post("/login", summary="Verify the Sklik token (client.loginByToken)")
async def login( async def login(
token: str = Depends(get_sklik_token), token: str = Depends(get_sklik_token),
@@ -76,9 +297,121 @@ async def limits(
user_id: int | None = Depends(_optional_user_id), user_id: int | None = Depends(_optional_user_id),
) -> Any: ) -> Any:
async with SklikClient(token, user_id=user_id) as client: 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( @router.post(
"/report/{entity}", "/report/{entity}",
summary="Create and read a Sklik stats report (createReport + readReport)", summary="Create and read a Sklik stats report (createReport + readReport)",
@@ -105,9 +438,183 @@ async def report(
status=400, status=400,
) )
async with SklikClient(token, user_id=user_id) as client: 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( @router.post(
"/rpc/{method}", "/rpc/{method}",
summary="Generic authenticated Sklik call (any method)", summary="Generic authenticated Sklik call (any method)",
@@ -116,7 +623,9 @@ async def rpc(
method: str = Path( method: str = Path(
..., ...,
description="Sklik method name, e.g. campaigns.list, groups.list, " 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( args: list[Any] = Body(
default=[], default=[],
@@ -127,5 +636,13 @@ async def rpc(
token: str = Depends(get_sklik_token), token: str = Depends(get_sklik_token),
user_id: int | None = Depends(_optional_user_id), user_id: int | None = Depends(_optional_user_id),
) -> Any: ) -> 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: 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 logging_config.py get_logger(); secrets are never logged
errors.py MissingCredentialsError, UpstreamError + handlers errors.py MissingCredentialsError, UpstreamError + handlers
credentials.py X- header dependencies (GA / GSC / Ads / Sklik) 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/ clients/
google.py shared Google client: Bearer/SA token minting + requests 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/ routers/
meta.py /health, /version meta.py /health, /version
ga_data.py /ga/data/... (Google Analytics Data) 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. | | 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. | | 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 ## Deliberately not wired
- **Write operations** across the Google services: GA Admin (create/update - **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 sites), Google Ads mutates (create/update campaigns etc.). All requested
scopes are read-only; add the read-write scope + endpoints if management is scopes are read-only; add the read-write scope + endpoints if management is
needed later. Google Ads exposes reporting (GAQL) only for now. 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`: - **Sklik header-credential encryption.** Same deferral as `idoklad`/`csob`:
header values are plaintext over TLS for now. header values are plaintext over TLS for now.
- **Sklik session reuse across requests** — the chosen model logs in per - **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). | | POST | `/sklik/login` | Verify the token. Returns `{valid, status, statusMessage}` (no session). |
| GET | `/sklik/limits` | `api.limits` — quotas and the `statsDataLimit`. | | 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/report/{entity}` | `createReport` + paged `readReport` for an entity. |
| POST | `/sklik/rpc/{method}` | Generic authenticated call to any method. | | 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 ### Report helper
`entity``campaigns, groups, ads, keywords, queries, sitelinks, productSets, `entity``campaigns, groups, ads, keywords, queries, sitelinks, productSets,
@@ -71,7 +234,18 @@ Example body for `POST /sklik/report/campaigns`:
### Generic RPC ### Generic RPC
`POST /sklik/rpc/{method}` with a JSON-array body of the arguments **after** the `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 ```bash
# List campaigns # List campaigns