sklik rozsireni na typove metody
This commit is contained in:
@@ -29,7 +29,12 @@ Interactive docs (Swagger UI): `/docs` — publicly `https://services.csbot.cz/a
|
|||||||
| Google Ads | POST | `/googleads/customers/{id}/search`, `/googleads/customers/{id}/searchStream` |
|
| Google Ads | 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.
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"""Idempotency for write operations.
|
||||||
|
|
||||||
|
Why this exists: if a create request times out on the network, the caller cannot
|
||||||
|
know whether the campaign was created. Retrying without protection creates a
|
||||||
|
second campaign that also spends money. An idempotency key makes the retry
|
||||||
|
return the ORIGINAL result instead of doing the work twice.
|
||||||
|
|
||||||
|
Scope and honest limitations - this is an **in-memory, per-container** store:
|
||||||
|
|
||||||
|
* it does not survive a restart or a redeploy;
|
||||||
|
* it is not shared between replicas, so with more than one container a retry
|
||||||
|
can land on a replica that has never seen the key.
|
||||||
|
|
||||||
|
That is a deliberate trade-off matching the stateless design (the same one the
|
||||||
|
Google token cache in ``clients/google.py`` makes). It removes the common
|
||||||
|
failure - an immediate client retry after a timeout - and it is a large
|
||||||
|
improvement over nothing. If Sklik writes ever run at a volume where a duplicated
|
||||||
|
campaign is unacceptable, this needs to move to shared storage (Redis) and that
|
||||||
|
should be a conscious decision, not a surprise. Keys are hashed together with the
|
||||||
|
caller's token so two tenants can never read each other's stored responses.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from . import config
|
||||||
|
from .errors import UpstreamError
|
||||||
|
from .logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _Entry:
|
||||||
|
#: None while the original request is still running.
|
||||||
|
response: Any
|
||||||
|
created_at: float
|
||||||
|
in_flight: bool
|
||||||
|
|
||||||
|
|
||||||
|
_store: dict[str, _Entry] = {}
|
||||||
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_key(idempotency_key: str, token: str, operation: str) -> str:
|
||||||
|
# Hashed so neither the raw token nor the caller's key sits in a dict key we
|
||||||
|
# might later log. The token is part of the key so tenants stay isolated.
|
||||||
|
raw = f"{idempotency_key}|{token}|{operation}"
|
||||||
|
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _purge_expired(now: float) -> None:
|
||||||
|
"""Drop entries past their TTL. Called under the lock."""
|
||||||
|
ttl = config.SKLIK_IDEMPOTENCY_TTL_SECONDS
|
||||||
|
expired = [k for k, e in _store.items() if now - e.created_at > ttl]
|
||||||
|
for key in expired:
|
||||||
|
del _store[key]
|
||||||
|
|
||||||
|
|
||||||
|
def begin(idempotency_key: str, token: str, operation: str) -> tuple[str, Any | None]:
|
||||||
|
"""Claim an idempotency key before performing a write.
|
||||||
|
|
||||||
|
Returns ``(cache_key, previous_response)``. A non-None previous response
|
||||||
|
means this exact request already succeeded and must NOT be performed again -
|
||||||
|
return the stored response instead.
|
||||||
|
|
||||||
|
Raises ``UpstreamError`` (409) if an identical request is still in flight,
|
||||||
|
so two concurrent retries cannot both reach Sklik.
|
||||||
|
"""
|
||||||
|
key = _cache_key(idempotency_key, token, operation)
|
||||||
|
now = time.time()
|
||||||
|
with _lock:
|
||||||
|
_purge_expired(now)
|
||||||
|
entry = _store.get(key)
|
||||||
|
if entry is None:
|
||||||
|
_store[key] = _Entry(response=None, created_at=now, in_flight=True)
|
||||||
|
return key, None
|
||||||
|
if entry.in_flight:
|
||||||
|
raise UpstreamError(
|
||||||
|
"A request with this X-Idempotency-Key is still in progress. "
|
||||||
|
"Wait for it to finish rather than retrying.",
|
||||||
|
status=409,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Idempotent replay for %s (key hash %s...) - not calling Sklik again.",
|
||||||
|
operation,
|
||||||
|
key[:12],
|
||||||
|
)
|
||||||
|
return key, entry.response
|
||||||
|
|
||||||
|
|
||||||
|
def complete(cache_key: str, response: Any) -> None:
|
||||||
|
"""Store the successful result so a retry replays it."""
|
||||||
|
with _lock:
|
||||||
|
entry = _store.get(cache_key)
|
||||||
|
if entry is None:
|
||||||
|
# Purged mid-flight (very long request vs. a short TTL). Recreate it
|
||||||
|
# rather than losing the record silently.
|
||||||
|
_store[cache_key] = _Entry(
|
||||||
|
response=response, created_at=time.time(), in_flight=False
|
||||||
|
)
|
||||||
|
logger.warning(
|
||||||
|
"Idempotency entry %s... vanished before completion; recreated.",
|
||||||
|
cache_key[:12],
|
||||||
|
)
|
||||||
|
return
|
||||||
|
entry.response = response
|
||||||
|
entry.in_flight = False
|
||||||
|
|
||||||
|
|
||||||
|
def abandon(cache_key: str) -> None:
|
||||||
|
"""Release a claimed key after a FAILED write, so the caller can retry.
|
||||||
|
|
||||||
|
A failed create must stay retryable: keeping the key would lock the caller
|
||||||
|
out of ever retrying with it.
|
||||||
|
"""
|
||||||
|
with _lock:
|
||||||
|
entry = _store.get(cache_key)
|
||||||
|
if entry is not None and entry.in_flight:
|
||||||
|
del _store[cache_key]
|
||||||
+37
@@ -39,6 +39,7 @@ služba si nic neukládá. Vyplníte je v Swaggeru po kliknutí na **Try it out*
|
|||||||
| Google Ads | `X-GAds-Login-Customer-Id`, `X-GAds-Quota-Project` | ne |
|
| 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
@@ -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))
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
"""Guard rails for Sklik write operations.
|
||||||
|
|
||||||
|
1. **Everything is created paused.** This is a requirement of the integration:
|
||||||
|
entities are created in a paused state and activation is always manual.
|
||||||
|
Sklik's ``status`` defaults to ``active``, so an omitted field would silently
|
||||||
|
produce a *live* campaign - the proxy therefore forces
|
||||||
|
``status: "suspend"`` on every created entity. This one is not configurable;
|
||||||
|
it is the point of the endpoint.
|
||||||
|
2. **Budget ceilings - OPT-IN, disabled by default.** ``dayBudget`` /
|
||||||
|
``totalBudget`` / ``cpc`` are only checked when the corresponding
|
||||||
|
``SKLIK_MAX_*`` config value is non-zero. Out of the box the proxy passes the
|
||||||
|
caller's numbers through untouched; approval lives on the caller's side.
|
||||||
|
Enabling a ceiling catches the misplaced decimal point (Sklik works in
|
||||||
|
halers, so "50000" meant as korunas is 500 Kc) before it reaches Sklik.
|
||||||
|
3. **Idempotency - opt-in per request**, see ``app.idempotency``.
|
||||||
|
|
||||||
|
Basic shape validation (required fields, integer amounts) always applies: it
|
||||||
|
turns a generic upstream rejection into a message that says what is wrong.
|
||||||
|
|
||||||
|
Amounts are in HALERS throughout (100 halers = 1 Kc), matching the Sklik API.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from . import config
|
||||||
|
from .errors import UpstreamError
|
||||||
|
from .logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
#: Sklik's paused state. The alternative is "active".
|
||||||
|
PAUSED_STATUS = "suspend"
|
||||||
|
ACTIVE_STATUS = "active"
|
||||||
|
_VALID_STATUSES = (ACTIVE_STATUS, PAUSED_STATUS)
|
||||||
|
|
||||||
|
#: Money fields per entity: field name -> (config ceiling, human label).
|
||||||
|
_MONEY_FIELDS: dict[str, dict[str, tuple[int, str]]] = {
|
||||||
|
"campaigns": {
|
||||||
|
"dayBudget": (config.SKLIK_MAX_DAY_BUDGET_HALERS, "daily budget"),
|
||||||
|
"totalBudget": (config.SKLIK_MAX_TOTAL_BUDGET_HALERS, "total budget"),
|
||||||
|
},
|
||||||
|
"groups": {
|
||||||
|
"cpc": (config.SKLIK_MAX_CPC_HALERS, "max CPC"),
|
||||||
|
"cpt": (config.SKLIK_MAX_CPC_HALERS, "max CPT"),
|
||||||
|
},
|
||||||
|
"ads": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
#: Fields Sklik requires on create, checked here so the caller gets a clear
|
||||||
|
#: message instead of a generic upstream rejection.
|
||||||
|
_REQUIRED_FIELDS: dict[str, tuple[str, ...]] = {
|
||||||
|
"campaigns": ("name", "dayBudget", "type"),
|
||||||
|
"groups": ("campaignId", "name", "cpc"),
|
||||||
|
"ads": ("groupId",),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _halers_to_czk(value: int) -> str:
|
||||||
|
return f"{value / 100:.2f} Kc"
|
||||||
|
|
||||||
|
|
||||||
|
def _check_money(entity: str, index: int, clean: dict) -> None:
|
||||||
|
"""Reject amounts above a configured ceiling. 0 in config = no ceiling."""
|
||||||
|
for field, (ceiling, label) in _MONEY_FIELDS.get(entity, {}).items():
|
||||||
|
value = clean.get(field)
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
if not isinstance(value, int) or isinstance(value, bool):
|
||||||
|
raise UpstreamError(
|
||||||
|
f"Item {index}: '{field}' must be an integer amount in halers "
|
||||||
|
"(100 halers = 1 Kc).",
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
if value < 0:
|
||||||
|
raise UpstreamError(
|
||||||
|
f"Item {index}: '{field}' must not be negative.", status=400
|
||||||
|
)
|
||||||
|
if ceiling > 0 and value > ceiling:
|
||||||
|
raise UpstreamError(
|
||||||
|
f"Item {index}: {label} {_halers_to_czk(value)} exceeds the "
|
||||||
|
f"configured ceiling {_halers_to_czk(ceiling)}. Amounts are in "
|
||||||
|
"halers (100 = 1 Kc) - check for a misplaced decimal point, or "
|
||||||
|
"raise the limit in the service configuration.",
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_for_create(entity: str, items: list[Any]) -> list[dict]:
|
||||||
|
"""Validate and normalize a batch of entities for ``{entity}.create``.
|
||||||
|
|
||||||
|
Returns a new list; the caller's input is never mutated. Raises
|
||||||
|
``UpstreamError`` (400) on the first problem, naming the offending item's
|
||||||
|
index so a batch failure is diagnosable.
|
||||||
|
"""
|
||||||
|
if not isinstance(items, list) or not items:
|
||||||
|
raise UpstreamError(
|
||||||
|
f"Provide a non-empty JSON array of {entity} to create.", status=400
|
||||||
|
)
|
||||||
|
|
||||||
|
prepared: list[dict] = []
|
||||||
|
for index, item in enumerate(items):
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
raise UpstreamError(
|
||||||
|
f"Item {index} must be a JSON object describing one "
|
||||||
|
f"{entity[:-1]}.",
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
for field in _REQUIRED_FIELDS.get(entity, ()):
|
||||||
|
if item.get(field) in (None, ""):
|
||||||
|
raise UpstreamError(
|
||||||
|
f"Item {index}: '{field}' is required when creating "
|
||||||
|
f"{entity}.",
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
clean = dict(item)
|
||||||
|
|
||||||
|
# Rule 1: always paused, never negotiable.
|
||||||
|
requested = clean.get("status")
|
||||||
|
if requested is not None and requested != PAUSED_STATUS:
|
||||||
|
# Not an error - we simply override it - but it must be visible.
|
||||||
|
logger.warning(
|
||||||
|
"Item %d requested status=%r for %s; forcing %r. This proxy "
|
||||||
|
"cannot create active entities.",
|
||||||
|
index,
|
||||||
|
requested,
|
||||||
|
entity,
|
||||||
|
PAUSED_STATUS,
|
||||||
|
)
|
||||||
|
clean["status"] = PAUSED_STATUS
|
||||||
|
|
||||||
|
# Rule 2: money ceilings - only where one is configured (0 = disabled).
|
||||||
|
_check_money(entity, index, clean)
|
||||||
|
|
||||||
|
prepared.append(clean)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Prepared %d %s for creation (all forced to status=%s).",
|
||||||
|
len(prepared),
|
||||||
|
entity,
|
||||||
|
PAUSED_STATUS,
|
||||||
|
)
|
||||||
|
return prepared
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_for_update(entity: str, items: list[Any]) -> list[dict]:
|
||||||
|
"""Validate a batch of entities for ``{entity}.update``.
|
||||||
|
|
||||||
|
Unlike create, update does NOT force a status: changing ``status`` is how a
|
||||||
|
campaign is paused or resumed, and this is ordinary CRUD. Only two checks
|
||||||
|
apply - the money ceilings (when configured) and, if the operator enabled
|
||||||
|
``SKLIK_BLOCK_ACTIVATION``, a refusal to set ``status: "active"`` so that
|
||||||
|
starting a campaign stays a manual action.
|
||||||
|
|
||||||
|
``id`` is required on every item; everything else is optional and only the
|
||||||
|
supplied fields are changed upstream.
|
||||||
|
"""
|
||||||
|
if not isinstance(items, list) or not items:
|
||||||
|
raise UpstreamError(
|
||||||
|
f"Provide a non-empty JSON array of {entity} to update.", status=400
|
||||||
|
)
|
||||||
|
|
||||||
|
prepared: list[dict] = []
|
||||||
|
for index, item in enumerate(items):
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
raise UpstreamError(
|
||||||
|
f"Item {index} must be a JSON object with an 'id' and the "
|
||||||
|
"fields to change.",
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
if item.get("id") in (None, ""):
|
||||||
|
raise UpstreamError(
|
||||||
|
f"Item {index}: 'id' is required when updating {entity}.",
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
clean = dict(item)
|
||||||
|
status = clean.get("status")
|
||||||
|
if status is not None:
|
||||||
|
if status not in _VALID_STATUSES:
|
||||||
|
raise UpstreamError(
|
||||||
|
f"Item {index}: status must be one of "
|
||||||
|
+ ", ".join(_VALID_STATUSES)
|
||||||
|
+ f" (got {status!r}).",
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
if status == ACTIVE_STATUS and config.SKLIK_BLOCK_ACTIVATION:
|
||||||
|
raise UpstreamError(
|
||||||
|
f"Item {index}: activating entities through the API is "
|
||||||
|
"disabled (SKLIK_BLOCK_ACTIVATION). Start the campaign "
|
||||||
|
"manually in the Sklik UI.",
|
||||||
|
status=403,
|
||||||
|
)
|
||||||
|
|
||||||
|
_check_money(entity, index, clean)
|
||||||
|
prepared.append(clean)
|
||||||
|
|
||||||
|
activating = sum(1 for i in prepared if i.get("status") == ACTIVE_STATUS)
|
||||||
|
if activating:
|
||||||
|
# Activation starts spending - always visible in the log.
|
||||||
|
logger.warning(
|
||||||
|
"Updating %d %s to status=active (spending may start).",
|
||||||
|
activating,
|
||||||
|
entity,
|
||||||
|
)
|
||||||
|
logger.info("Prepared %d %s for update.", len(prepared), entity)
|
||||||
|
return prepared
|
||||||
|
|
||||||
|
|
||||||
|
def parse_ids(raw_ids: list[Any], entity: str) -> list[int]:
|
||||||
|
"""Validate a list of entity ids for remove/restore."""
|
||||||
|
if not isinstance(raw_ids, list) or not raw_ids:
|
||||||
|
raise UpstreamError(
|
||||||
|
f"Provide a non-empty list of {entity} ids.", status=400
|
||||||
|
)
|
||||||
|
ids: list[int] = []
|
||||||
|
for index, value in enumerate(raw_ids):
|
||||||
|
if isinstance(value, bool) or not isinstance(value, int):
|
||||||
|
raise UpstreamError(
|
||||||
|
f"Id at position {index} must be an integer (got {value!r}).",
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
ids.append(value)
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
def _ceiling(value: int) -> int | None:
|
||||||
|
"""0 in config means "no ceiling"; report that as null, not as zero."""
|
||||||
|
return value if value > 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
def budget_limits() -> dict[str, Any]:
|
||||||
|
"""The active ceilings, so callers can read them instead of guessing."""
|
||||||
|
return {
|
||||||
|
"currency": "CZK",
|
||||||
|
"unit": "haler (100 = 1 Kc)",
|
||||||
|
"maxDayBudget": _ceiling(config.SKLIK_MAX_DAY_BUDGET_HALERS),
|
||||||
|
"maxTotalBudget": _ceiling(config.SKLIK_MAX_TOTAL_BUDGET_HALERS),
|
||||||
|
"maxCpc": _ceiling(config.SKLIK_MAX_CPC_HALERS),
|
||||||
|
"budgetLimitsEnforced": any(
|
||||||
|
v > 0
|
||||||
|
for v in (
|
||||||
|
config.SKLIK_MAX_DAY_BUDGET_HALERS,
|
||||||
|
config.SKLIK_MAX_TOTAL_BUDGET_HALERS,
|
||||||
|
config.SKLIK_MAX_CPC_HALERS,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"createdStatus": PAUSED_STATUS,
|
||||||
|
"activationBlocked": config.SKLIK_BLOCK_ACTIVATION,
|
||||||
|
"note": (
|
||||||
|
"Everything created through this proxy is paused. null ceilings "
|
||||||
|
"mean no budget limit is enforced here - configure SKLIK_MAX_* to "
|
||||||
|
"enable one. Update can set status=active unless "
|
||||||
|
"activationBlocked is true."
|
||||||
|
),
|
||||||
|
}
|
||||||
@@ -36,9 +36,11 @@ app/
|
|||||||
logging_config.py get_logger(); secrets are never logged
|
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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user