sklik rozsireni na typove metody
This commit is contained in:
@@ -36,9 +36,11 @@ app/
|
||||
logging_config.py get_logger(); secrets are never logged
|
||||
errors.py MissingCredentialsError, UpstreamError + handlers
|
||||
credentials.py X- header dependencies (GA / GSC / Ads / Sklik)
|
||||
idempotency.py in-memory idempotency store for writes (opt-in per request)
|
||||
sklik_guards.py write guard rails: forced paused status, optional budget caps
|
||||
clients/
|
||||
google.py shared Google client: Bearer/SA token minting + requests
|
||||
sklik_client.py Sklik JSON-RPC client (login + session + report paging)
|
||||
sklik_client.py Sklik JSON-RPC client (login + session + list/report paging)
|
||||
routers/
|
||||
meta.py /health, /version
|
||||
ga_data.py /ga/data/... (Google Analytics Data)
|
||||
@@ -64,6 +66,23 @@ routes are unprefixed (Caddy `handle_path` strips the prefix).
|
||||
| Google Ads | `X-GAds-Developer-Token` (req) + `X-GAds-Access-Token` **or** `X-GAds-Credentials` (+ `X-GAds-Login-Customer-Id`, `X-GAds-Quota-Project`) | Same OAuth (scope `adwords`) plus `developer-token` / `login-customer-id` headers forwarded upstream. |
|
||||
| Sklik | `X-Sklik-Token` (+ `X-Sklik-User-Id`) | `client.loginByToken` per request → session injected into the call. |
|
||||
|
||||
## Writes
|
||||
|
||||
**Sklik is the only upstream with write endpoints** — full CRUD over campaigns,
|
||||
groups and ads (create / update / remove / restore). The pattern established
|
||||
there — create paused, optional budget ceilings, optional idempotency key — is
|
||||
the template for any future write support (Google Ads mutates, Meta campaign
|
||||
management in the sibling `meta` service).
|
||||
|
||||
The one non-negotiable rule: **entities are always created paused**. Sklik
|
||||
defaults `status` to `active`, so this must be forced server-side. Update does
|
||||
*not* force a status (that is how you pause or resume a campaign), but an
|
||||
operator can refuse activation entirely with `SKLIK_BLOCK_ACTIVATION`. Budget
|
||||
ceilings and idempotency are opt-in, since the approval flow lives on the
|
||||
caller's side. Removal is Sklik's own soft delete and is reversible.
|
||||
|
||||
See `sklik.md` for details.
|
||||
|
||||
## Deliberately not wired
|
||||
|
||||
- **Write operations** across the Google services: GA Admin (create/update
|
||||
@@ -71,6 +90,9 @@ routes are unprefixed (Caddy `handle_path` strips the prefix).
|
||||
sites), Google Ads mutates (create/update campaigns etc.). All requested
|
||||
scopes are read-only; add the read-write scope + endpoints if management is
|
||||
needed later. Google Ads exposes reporting (GAQL) only for now.
|
||||
- **Sklik keyword / sitelink / product-set management.** Only campaigns, groups
|
||||
and ads have typed CRUD; the rest of the Sklik API remains reachable through
|
||||
`POST /sklik/rpc/{method}` without guard rails.
|
||||
- **Sklik header-credential encryption.** Same deferral as `idoklad`/`csob`:
|
||||
header values are plaintext over TLS for now.
|
||||
- **Sklik session reuse across requests** — the chosen model logs in per
|
||||
|
||||
+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). |
|
||||
| GET | `/sklik/limits` | `api.limits` — quotas and the `statsDataLimit`. |
|
||||
| GET | `/sklik/write-limits` | Guard rails applied to writes. No credentials needed. |
|
||||
| GET | `/sklik/campaigns` | `campaigns.list`, all pages collected. |
|
||||
| GET | `/sklik/groups` | `groups.list`, filterable by `campaign_ids`. |
|
||||
| GET | `/sklik/ads` | `ads.list`, filterable by `campaign_ids` / `group_ids`. |
|
||||
| POST | `/sklik/campaigns` `/sklik/groups` `/sklik/ads` | **Create — always paused.** |
|
||||
| PUT | `/sklik/campaigns` `/sklik/groups` `/sklik/ads` | **Update** by id (partial). |
|
||||
| DELETE | `/sklik/campaigns` `/sklik/groups` `/sklik/ads` | **Remove** (`?ids=1,2,3`) — reversible. |
|
||||
| POST | `/sklik/{entity}/restore` | **Restore** removed entities (`?ids=1,2,3`). |
|
||||
| POST | `/sklik/report/{entity}` | `createReport` + paged `readReport` for an entity. |
|
||||
| POST | `/sklik/rpc/{method}` | Generic authenticated call to any method. |
|
||||
|
||||
> The proxy never returns the Sklik `session` — it is a credential, and
|
||||
> AGENTS.md forbids returning secrets from ordinary endpoints. The session is
|
||||
> managed internally and callers have no use for it.
|
||||
|
||||
### Listing entities
|
||||
|
||||
`GET /sklik/campaigns`, `/sklik/groups`, `/sklik/ads` wrap `{entity}.list` and
|
||||
page through the whole result set (offset/limit, `SKLIK_LIST_PAGE_LIMIT` rows
|
||||
per page, capped by `SKLIK_LIST_MAX_PAGES`):
|
||||
|
||||
```json
|
||||
{ "totalCount": 42, "returnedCount": 42, "truncated": false, "campaigns": [ ... ] }
|
||||
```
|
||||
|
||||
Query parameters:
|
||||
|
||||
| Parameter | Endpoints | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `ids` | all | Comma-separated ids of the entity itself. |
|
||||
| `campaign_ids` | groups, ads | Restrict to these campaigns. |
|
||||
| `group_ids` | ads | Restrict to these groups. |
|
||||
| `is_deleted` | all | `true`/`false`. Omit to get both. |
|
||||
| `display_columns` | all | Comma-separated columns; defaults to a useful subset. |
|
||||
|
||||
> Sklik's `campaigns.list` filter supports only `ids` and `isDeleted` — there is
|
||||
> **no status filter upstream**, so filter on `status` in the returned rows.
|
||||
> `groups.list` and `ads.list` do support parent filters (`campaign.ids`,
|
||||
> `group.ids`), which is what `campaign_ids` / `group_ids` map to.
|
||||
|
||||
## Writes — full CRUD over campaigns / groups / ads
|
||||
|
||||
| Operation | Endpoint | Upstream | Body / params |
|
||||
| --- | --- | --- | --- |
|
||||
| Create | `POST /sklik/{entity}` | `{entity}.create` | JSON array of structs |
|
||||
| Update | `PUT /sklik/{entity}` | `{entity}.update` | JSON array of structs, `id` required |
|
||||
| Remove | `DELETE /sklik/{entity}?ids=1,2` | `{entity}.remove` | ids in the query |
|
||||
| Restore | `POST /sklik/{entity}/restore?ids=1,2` | `{entity}.restore` | ids in the query |
|
||||
|
||||
`{entity}` ∈ `campaigns`, `groups`, `ads`. The structs are exactly the ones Sklik
|
||||
documents ([campaigns.create](https://api.sklik.cz/drak/campaigns.create.html),
|
||||
[campaigns.update](https://api.sklik.cz/drak/campaigns.update.html), and the
|
||||
`groups.*` / `ads.*` equivalents). Sklik batches are **all-or-nothing**: if one
|
||||
item fails, nothing is applied.
|
||||
|
||||
### Create — everything is paused, not configurable
|
||||
|
||||
Sklik's `status` field **defaults to `active`**, so an omitted status would
|
||||
create a *live, spending* campaign. The proxy therefore forces
|
||||
`status: "suspend"` on every created entity and ignores any other value you
|
||||
send (the override is logged).
|
||||
|
||||
### Update — ordinary CRUD, status is not forced
|
||||
|
||||
`PUT` changes only the fields you send; `id` is required per item. Unlike
|
||||
create, `status` is passed through as given — setting `suspend` is how you pause
|
||||
a running campaign and `active` is how you resume one, so forcing a value here
|
||||
would break half the use cases.
|
||||
|
||||
That does mean **update can start spending**. If you want activation to stay a
|
||||
manual action in the Sklik UI, set `SKLIK_BLOCK_ACTIVATION=true`: `status:
|
||||
"active"` is then refused with `403` while pausing still works. Default is
|
||||
`false` (both directions allowed). Every activation is logged at WARNING either
|
||||
way.
|
||||
|
||||
> **Ads: changing the creative replaces the ad.** Sklik cannot edit an existing
|
||||
> ad's headlines, description or URLs — it deletes the old ad and creates a new
|
||||
> one, so the ad gets a **new id**. Re-read the group's ads after such an
|
||||
> update. Changing only `status` keeps the id.
|
||||
|
||||
`type` cannot be changed on a campaign.
|
||||
|
||||
### Remove and restore — reversible
|
||||
|
||||
Sklik's removal is a soft delete: "the campaign is not really removed; it is
|
||||
only marked as removed". Every `DELETE` therefore has a matching restore
|
||||
endpoint, and removed entities still show up in listings unless you filter with
|
||||
`is_deleted=false`.
|
||||
|
||||
```bash
|
||||
curl -X DELETE ".../sklik/campaigns?ids=123456" -H "X-Sklik-Token: <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
|
||||
|
||||
`entity` ∈ `campaigns, groups, ads, keywords, queries, sitelinks, productSets,
|
||||
@@ -71,7 +234,18 @@ Example body for `POST /sklik/report/campaigns`:
|
||||
### Generic RPC
|
||||
|
||||
`POST /sklik/rpc/{method}` with a JSON-array body of the arguments **after** the
|
||||
session struct (which the proxy injects). Examples:
|
||||
session struct (which the proxy injects). Reaches **any** method, including
|
||||
mutating ones — that is the long-standing behaviour and it is unchanged.
|
||||
|
||||
> Calls made this way bypass the typed write endpoints' guard rails: nothing
|
||||
> forces `status: "suspend"`, no budget ceiling applies and there is no
|
||||
> idempotency. For creating campaigns prefer `POST /sklik/campaigns`.
|
||||
> An operator who wants to enforce that can set
|
||||
> `SKLIK_RPC_ALLOW_MUTATIONS=false`, which makes this endpoint refuse
|
||||
> `.create` / `.update` / `.remove` / `.delete` / `.restore` / `.setStatus`
|
||||
> with `403`. Default is `true` (everything allowed).
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
# List campaigns
|
||||
|
||||
Reference in New Issue
Block a user