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