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
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Changelog

All notable changes to `py-sendly` are documented here. This project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Changed

- Re-synced the vendored OpenAPI spec (`tests/fixtures/openapi.json`) to the
committed monorepo contract. The client stays thin (opaque `Mapping` bodies),
so these are contract/behaviour clarifications rather than method-signature
changes:
- **Deletes now return HTTP `200` with the deleted resource's id** (was `204`
No Content) for `contacts.delete` and `templates.delete`. The SDK still
discards the body and returns `None` — no consumer change.
- **Invalid input now raises `SendlyValidationError` from HTTP `422`**
(`error_code == "VALIDATION_ERROR"`) with a per-field breakdown at
`err.body["error"]["details"]["errors"]`. Previously invalid input came back
as `400`. Both `400` and `422` map to `SendlyValidationError`, so
`except SendlyValidationError` continues to catch validation failures.
- **Contacts bulk ops (`bulk_create`, `bulk_delete`) against an unresolved
project now return `422 VALIDATION_ERROR`** (was a `NO_PROJECT` error).
- **`templates.list` is cursor-paginated** (`limit` / `cursor`) — the former
`page` / `pageSize` query params are gone. `contacts.list` was already
cursor-based and is unchanged.
- Error envelopes on migrated routes now include `success: false` alongside
`error.{message,code}`; error parsing reads `message`/`code` and is
unaffected by the additive fields.
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ sendly.domains.delete("d_123")
```python
sendly.templates.create({"name": "Welcome", "subject": "Welcome", "body": "<p>Hi</p>",
"from": "a@you.com", "type": "MARKETING"})
sendly.templates.list({"page": 1, "pageSize": 25})
sendly.templates.list({"limit": 25}) # cursor pagination: pass {"cursor": ...} for the next page
sendly.templates.get("t_123")
sendly.templates.update("t_123", {"name": "Welcome v2"})
sendly.templates.delete("t_123")
Expand Down Expand Up @@ -196,7 +196,7 @@ except SendlyError as err:

| Exception | HTTP status |
| --- | --- |
| `SendlyValidationError` | 400 |
| `SendlyValidationError` | 400, 422 |
| `SendlyAuthenticationError` | 401 |
| `SendlyPermissionError` | 403 |
| `SendlyNotFoundError` | 404 |
Expand All @@ -207,6 +207,11 @@ except SendlyError as err:

All inherit from `SendlyError`.

Invalid input raises `SendlyValidationError`. Migrated routes report it as HTTP
`422` with `error_code == "VALIDATION_ERROR"` and a per-field breakdown under
`err.body["error"]["details"]["errors"]`; legacy/malformed requests still use
`400`. Both surface as `SendlyValidationError`.

## Verifying webhooks

Every delivery is signed. Verify it against the **raw** request body — do not
Expand Down
3 changes: 2 additions & 1 deletion src/sendly/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,8 @@ def request(
except httpx.HTTPError as exc:
raise SendlyConnectionError(f"Sendly request failed: {exc}", exc) from exc

# 204 No Content (DELETE endpoints) or caller-forced no-content.
# 204 No Content or caller-forced no-content (DELETE endpoints, which
# now respond 200 with an id body the SDK intentionally discards).
if response.status_code == 204 or no_content:
if not response.is_success:
self._raise_for_error(response)
Expand Down
9 changes: 8 additions & 1 deletion src/sendly/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,12 @@ def __init__(self, status_code: int, error_code: str, message: str, body: Any =


class SendlyValidationError(SendlyError):
"""400 — request body or query failed validation."""
"""400 / 422 — request body or query failed validation.

Migrated routes return ``422`` with a ``VALIDATION_ERROR`` code (and an
``error.details.errors`` list); legacy/malformed-request paths still use
``400``. Both map here so ``except SendlyValidationError`` catches either.
"""


class SendlyAuthenticationError(SendlyError):
Expand Down Expand Up @@ -76,6 +81,8 @@ def error_from_response(
return SendlyPermissionError(status_code, error_code, message, body)
if status_code == 404:
return SendlyNotFoundError(status_code, error_code, message, body)
if status_code == 422:
return SendlyValidationError(status_code, error_code, message, body)
if status_code == 409:
return SendlyConflictError(status_code, error_code, message, body)
if status_code == 429:
Expand Down
3 changes: 2 additions & 1 deletion src/sendly/resources/contacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,8 @@ def update(self, id: str, body: Body) -> ContactRecord:
return record

def delete(self, id: str) -> None:
"""Delete a contact. Returns 204."""
"""Delete a contact. Returns ``None`` (the API responds 200 with the
deleted contact's id)."""
self._client.request(
method="DELETE", path=f"/api/contacts/{encode_path_segment(id)}", no_content=True
)
7 changes: 5 additions & 2 deletions src/sendly/resources/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ def create(self, body: Body) -> TemplateRecord:
return record

def list(self, query: Query | None = None) -> TemplateListResponse:
"""List templates with offset pagination + optional type filter."""
"""List templates with cursor pagination (``limit``/``cursor``) + optional
type filter."""
response: TemplateListResponse = self._client.request(
method="GET", path="/api/templates", query=query
)
Expand All @@ -52,7 +53,9 @@ def update(self, id: str, body: Body) -> TemplateRecord:
return record

def delete(self, id: str) -> None:
"""Delete a template. Returns 204 unless still referenced."""
"""Delete a template. Returns ``None`` (the API responds 200 with the
deleted template's id); raises :class:`SendlyConflictError` if the
template is still referenced."""
self._client.request(
method="DELETE", path=f"/api/templates/{encode_path_segment(id)}", no_content=True
)
Loading
Loading