sklik rozsireni na typove metody
This commit is contained in:
+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
|
||||
``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))
|
||||
|
||||
Reference in New Issue
Block a user