Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,55 @@ changes — see below.
hierarchy, configuration, and async/retry/timeout behavior.

### Fixed
- **Several typed models had wrong or missing fields**, found via a platform
re-audit (ADR-022) of fixtures used to originally build them — `extra="allow"`
meant a wrong alias silently yielded `None` rather than erroring, so these
went undetected until verified against live staging response bodies:
- `TrustScoreResult`: added `source` and `created_at` (the latter arrives as
a raw epoch-milliseconds integer, not the Firestore `{_seconds,...}` shape
the shared coercion validator handles — given its own field-level
validator). `short` was already correct; an earlier pass had also added an
unused `short_code` field guessing the wire key was `shortCode` — kept for
backward compatibility but confirmed it's never actually populated.
- `NamespaceInfo`: added `can_claim_custom_domain`, `can_claim_subdomain`,
`namespace_data` (the real fields) — `upgrade_required` (kept for backward
compatibility) is never actually sent by the platform.
- `AggregateAnalytics`: added `bot_clicks_excluded` (was missing; every other
field — `clicks_by_day`, `country_breakdown`, `device_breakdown`, etc. —
was already correctly named).
- `Link`: added `geo_restriction`, `og_meta`, `is_custom`, `is_disabled`,
`disabled_reason`, `trust_score`, `trust_status`, `threats` — all present
on real responses but previously inaccessible except via `.model_extra`.
- `affiliate.get_limits()`, `custom_domains.add()`, and
`webhooks.list_event_types()` return raw, untyped dicts by design (always
have) — there's no wrong-alias risk for these three since nothing is
dropped; upgrading them to typed models would change their return type
(a breaking change) and was left out of scope for this fix.
- `AffiliateProgram` (ADR-024, a second re-audit round): `cookie_days`
(kept as the field/kwarg name for backward compatibility) now reads/writes
the correct wire key `cookieDurationDays`, not `cookieDays` — this was
wrong on both the request side (`create_program()`/`update_program()`) and
the response side. Added `merchant_id`, `max_partners`, `partner_count`,
`is_public`, `created_at`, `updated_at` (all present on the owned-program
endpoints; `discover()`'s public-summary response is a subset, which the
model already tolerates since every field is `Optional`).
`list_partners()`/`list_partnerships()`/`join()`/`get_partnership_stats()`
return raw dicts by design (same as the three above) — confirmed no change
needed; a typed `AffiliatePartnership` model is a 2.0 candidate. **Open
question sent to `awsys-orch`**: `create_program()`'s request body sends
`commissionType`/`cpcRate`/`cpaRate` (existing behavior, unchanged here) —
the platform-verified fixture's minimal create-request example shows a
single `commissionRate` field instead, which may indicate the request
shape itself is wrong too; left unchanged pending confirmation rather than
guessing at a restructure that could break working creates in a different
way.
- **`utm_templates.list()`** read `utmTemplates` off `/api/v1/me`, a field the
platform never actually populated — every call silently returned an empty
list. The platform added a real `GET /api/user/utm-templates` route (#833);
`list()` now calls it and reads its `templates` array. `create()` was already
sending the correct `source`/`medium`/`campaign` body fields — no change
needed there, the platform-side 500 that used to accompany it (#831) was a
server bug, not a client wire-format mismatch.
- **`analytics.get_recent_clicks()`** called `/api/user/recent-clicks`, a path that
never existed on the platform (always 404'd). Now calls `/api/user/clicks/recent`
and supports a `since` parameter.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ client.namespace.release()

```python
client.utm_templates.create("Launch", "newsletter", "email", "sept")
for t in client.utm_templates.list(): # derived from /api/v1/me — no dedicated list route
for t in client.utm_templates.list():
print(t.name)
client.utm_templates.delete(t.id)
```
Expand Down
4 changes: 2 additions & 2 deletions awsysco/async_resources/affiliate.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ def __init__(self, http: AsyncHttpClient) -> None:

async def create_program(self, name: str, commission_type: str, **kwargs: Any) -> AffiliateProgram:
body: Dict[str, Any] = {"name": name, "commissionType": commission_type}
field_map = {"description": "description", "cpc_rate": "cpcRate", "cpa_rate": "cpaRate", "cookie_days": "cookieDays"}
field_map = {"description": "description", "cpc_rate": "cpcRate", "cpa_rate": "cpaRate", "cookie_days": "cookieDurationDays"}
for k, v in kwargs.items():
body[field_map.get(k, k)] = v
data = await self._http.post("/api/affiliate/programs", json=body)
Expand All @@ -33,7 +33,7 @@ async def get_program(self, program_id: str) -> AffiliateProgram:

async def update_program(self, program_id: str, **kwargs: Any) -> AffiliateProgram:
body: Dict[str, Any] = {}
field_map = {"cpc_rate": "cpcRate", "cpa_rate": "cpaRate", "cookie_days": "cookieDays", "commission_type": "commissionType"}
field_map = {"cpc_rate": "cpcRate", "cpa_rate": "cpaRate", "cookie_days": "cookieDurationDays", "commission_type": "commissionType"}
for k, v in kwargs.items():
body[field_map.get(k, k)] = v
data = await self._http.patch(f"/api/affiliate/programs/{program_id}", json=body)
Expand Down
4 changes: 2 additions & 2 deletions awsysco/async_resources/utm_templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ def __init__(self, http: AsyncHttpClient) -> None:
self._http = http

async def list(self) -> List[UtmTemplate]:
resp = await self._http.get("/api/v1/me")
items = resp.get("utmTemplates", []) if isinstance(resp, dict) else []
resp = await self._http.get("/api/user/utm-templates")
items = resp.get("templates", []) if isinstance(resp, dict) else []
return [UtmTemplate.model_validate(item) for item in items]

async def create(self, name: str, source: str, medium: str, campaign: str, *, term: str = "", content: str = "") -> dict:
Expand Down
68 changes: 61 additions & 7 deletions awsysco/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Union

from pydantic import BaseModel, ConfigDict, Field, model_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from pydantic.alias_generators import to_camel


Expand Down Expand Up @@ -124,6 +124,14 @@ class Link(_CamelModel):
max_clicks: Optional[int] = None
expire_fallback_url: Optional[str] = None
password_protected: Optional[bool] = None
geo_restriction: Optional["GeoRestriction"] = None
og_meta: Optional["OgMeta"] = None
is_custom: Optional[bool] = None
is_disabled: Optional[bool] = None
disabled_reason: Optional[str] = None
trust_score: Optional[float] = None
trust_status: Optional[str] = None
threats: Optional[List[str]] = None


class LinkList(_CamelModel):
Expand Down Expand Up @@ -294,9 +302,11 @@ class QRSettings(_CamelModel):
class TrustScoreResult(_CamelModel):
"""Result of a URL trust/safety scan.

Wire keys are ``shortCode``/``trustScore``/``trustStatus`` — ``short``/``long``
are kept as separate (currently unpopulated) fields since the platform doesn't
send them under those names; removing them would be a breaking change.
Wire keys are ``short``/``trustScore``/``trustStatus``/``scannedAt``/``source``/
``createdAt`` (platform-verified against live staging). ``short_code`` is kept
as a separate (currently unpopulated) field for backward compatibility — an
earlier version of this model guessed the wire key was ``shortCode``, which
it isn't; removing the field would be a breaking change.
"""

short: Optional[str] = None
Expand All @@ -306,6 +316,22 @@ class TrustScoreResult(_CamelModel):
status: Optional[str] = Field(default=None, alias="trustStatus")
threats: Optional[List[str]] = None
scanned_at: Optional[str] = None
source: Optional[str] = None
created_at: Optional[str] = None

@field_validator("created_at", mode="before")
@classmethod
def _coerce_epoch_millis(cls, value: Any) -> Any:
# This endpoint's createdAt is a raw epoch-milliseconds integer, not the
# {_seconds,_nanoseconds} Firestore shape the shared base-model validator
# handles — normalize it the same way (to an ISO-8601 string) here.
if isinstance(value, bool) or not isinstance(value, (int, float)):
return value
try:
dt = datetime.fromtimestamp(value / 1000, tz=timezone.utc)
return dt.isoformat().replace("+00:00", "Z")
except (OverflowError, OSError, ValueError):
return str(value)


# ---------------------------------------------------------------------------
Expand All @@ -314,12 +340,23 @@ class TrustScoreResult(_CamelModel):


class NamespaceInfo(_CamelModel):
"""Namespace info for the authenticated user."""
"""Namespace info for the authenticated user.

``upgrade_required`` is kept for backward compatibility — the platform
doesn't actually send it (an earlier version of this model guessed it did);
``can_claim_custom_domain``/``can_claim_subdomain``/``namespace_data`` are
the real, platform-verified fields. ``namespace_data`` is a loose dict
(account-internal shape, e.g. ``userEmail``/``isActive``/``tier``/``userId``/
``claimedAt``) rather than its own typed model.
"""

has_access: Optional[bool] = None
namespace: Optional[str] = None
tier: Optional[str] = None
upgrade_required: Optional[bool] = None
can_claim_custom_domain: Optional[bool] = None
can_claim_subdomain: Optional[bool] = None
namespace_data: Optional[Dict[str, Any]] = None


class NamespaceCheckResult(_CamelModel):
Expand Down Expand Up @@ -430,16 +467,32 @@ class CustomDomain(_CamelModel):


class AffiliateProgram(_CamelModel):
"""An affiliate program."""
"""An affiliate program.

Shared between the owned-program endpoints (create/list/get/update — the
fully-populated shape) and ``discover()`` (a public subset: no
``merchant_id``/``max_partners``/``is_public``/timestamps) — every field is
``Optional`` so both shapes validate against the same model.

``cookie_days`` is kept as a field name for backward compatibility even
though the wire key is ``cookieDurationDays``, not ``cookieDays`` (an
earlier version of this model guessed wrong).
"""

id: Optional[str] = None
merchant_id: Optional[str] = None
name: Optional[str] = None
description: Optional[str] = None
commission_type: Optional[str] = None
cpc_rate: Optional[float] = None
cpa_rate: Optional[float] = None
cookie_days: Optional[int] = None
cookie_days: Optional[int] = Field(default=None, alias="cookieDurationDays")
max_partners: Optional[int] = None
partner_count: Optional[int] = None
status: Optional[str] = None
is_public: Optional[bool] = None
created_at: Optional[str] = None
updated_at: Optional[str] = None


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -606,6 +659,7 @@ class AggregateAnalytics(_CamelModel):
period: Optional[str] = None
total_clicks: Optional[int] = None
unique_visitors: Optional[int] = None
bot_clicks_excluded: Optional[int] = None
clicks_by_day: List[DayClicks] = Field(default_factory=list)
country_breakdown: Dict[str, int] = Field(default_factory=dict)
tier_limit: Optional[int] = None
Expand Down
4 changes: 2 additions & 2 deletions awsysco/resources/affiliate.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ def create_program(
"description": "description",
"cpc_rate": "cpcRate",
"cpa_rate": "cpaRate",
"cookie_days": "cookieDays",
"cookie_days": "cookieDurationDays",
}
for k, v in kwargs.items():
mapped = field_map.get(k, k)
Expand Down Expand Up @@ -84,7 +84,7 @@ def update_program(self, program_id: str, **kwargs: Any) -> AffiliateProgram:
field_map = {
"cpc_rate": "cpcRate",
"cpa_rate": "cpaRate",
"cookie_days": "cookieDays",
"cookie_days": "cookieDurationDays",
"commission_type": "commissionType",
}
for k, v in kwargs.items():
Expand Down
4 changes: 2 additions & 2 deletions awsysco/resources/utm_templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ def list(self) -> List[UtmTemplate]:
Returns:
A list of UtmTemplate objects.
"""
resp = self._http.get("/api/v1/me")
items = resp.get("utmTemplates", []) if isinstance(resp, dict) else []
resp = self._http.get("/api/user/utm-templates")
items = resp.get("templates", []) if isinstance(resp, dict) else []
return [UtmTemplate.model_validate(item) for item in items]

def create(
Expand Down
Loading
Loading