diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a328805 --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index cd89163..2888214 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,7 @@ sendly.domains.delete("d_123") ```python sendly.templates.create({"name": "Welcome", "subject": "Welcome", "body": "

Hi

", "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") @@ -196,7 +196,7 @@ except SendlyError as err: | Exception | HTTP status | | --- | --- | -| `SendlyValidationError` | 400 | +| `SendlyValidationError` | 400, 422 | | `SendlyAuthenticationError` | 401 | | `SendlyPermissionError` | 403 | | `SendlyNotFoundError` | 404 | @@ -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 diff --git a/src/sendly/client.py b/src/sendly/client.py index f0120d6..552bd65 100644 --- a/src/sendly/client.py +++ b/src/sendly/client.py @@ -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) diff --git a/src/sendly/errors.py b/src/sendly/errors.py index 2487575..10fbbe6 100644 --- a/src/sendly/errors.py +++ b/src/sendly/errors.py @@ -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): @@ -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: diff --git a/src/sendly/resources/contacts.py b/src/sendly/resources/contacts.py index 102fddb..ef74c12 100644 --- a/src/sendly/resources/contacts.py +++ b/src/sendly/resources/contacts.py @@ -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 ) diff --git a/src/sendly/resources/templates.py b/src/sendly/resources/templates.py index 7b2f884..18f66e6 100644 --- a/src/sendly/resources/templates.py +++ b/src/sendly/resources/templates.py @@ -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 ) @@ -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 ) diff --git a/tests/fixtures/openapi.json b/tests/fixtures/openapi.json index 71935d2..383f05a 100644 --- a/tests/fixtures/openapi.json +++ b/tests/fixtures/openapi.json @@ -71,6 +71,12 @@ "Error": { "type": "object", "properties": { + "success": { + "type": "boolean", + "enum": [ + false + ] + }, "error": { "type": "object", "properties": { @@ -79,6 +85,18 @@ }, "code": { "type": "string" + }, + "details": { + "type": "object", + "properties": { + "errors": { + "type": "array", + "items": {} + } + }, + "required": [ + "errors" + ] } }, "required": [ @@ -90,7 +108,7 @@ "required": [ "error" ], - "description": "Standard error envelope returned by all 4xx/5xx responses." + "description": "Standard error envelope returned by all 4xx/5xx responses. Migrated routes include `success: false`; 422 validation errors add `error.details.errors`." }, "SuccessEmpty": { "type": "object", @@ -107,29 +125,33 @@ ], "description": "Bare success envelope with no payload." }, - "Pagination": { + "IdResponse": { "type": "object", "properties": { - "page": { - "type": "integer" - }, - "pageSize": { - "type": "integer" - }, - "total": { - "type": "integer" + "success": { + "type": "boolean", + "enum": [ + true + ] }, - "totalPages": { - "type": "integer" + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "id" + ] } }, "required": [ - "page", - "pageSize", - "total", - "totalPages" + "success", + "data" ], - "description": "Page-based pagination metadata." + "description": "Success envelope carrying the affected resource's id, e.g. after a delete." }, "Contact": { "type": "object", @@ -187,34 +209,39 @@ ] }, "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Contact" - } - }, - "total": { - "type": "integer" - }, - "nextCursor": { - "type": [ - "string", - "null" - ] - }, - "cursor": { - "type": [ - "string", - "null" + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Contact" + } + }, + "total": { + "type": "integer" + }, + "nextCursor": { + "type": [ + "string", + "null" + ], + "description": "Cursor for the next page, or null on the last page." + }, + "hasMore": { + "type": "boolean" + } + }, + "required": [ + "data", + "total", + "nextCursor", + "hasMore" ] - }, - "hasMore": { - "type": "boolean" } }, "required": [ "success", - "data", - "total" + "data" ], "description": "Cursor-paginated list of contacts." }, @@ -297,23 +324,43 @@ "type": "object", "properties": { "success": { - "type": "boolean" + "type": "boolean", + "enum": [ + true + ] }, "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Template" - } - }, - "pagination": { - "$ref": "#/components/schemas/Pagination" + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Template" + } + }, + "total": { + "type": "integer" + }, + "cursor": { + "type": "string", + "description": "Cursor for the next page; omitted on the last page." + }, + "hasMore": { + "type": "boolean" + } + }, + "required": [ + "data", + "total", + "hasMore" + ] } }, "required": [ "success", "data" ], - "description": "Page-paginated list of templates." + "description": "Cursor-paginated list of templates." }, "Domain": { "type": "object", @@ -2346,6 +2393,16 @@ } } }, + "422": { + "description": "Validation failed — request body or query parameters did not match the schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "429": { "description": "Rate limit or billing limit exceeded", "content": { @@ -2459,6 +2516,16 @@ } } }, + "422": { + "description": "Validation failed — request body or query parameters did not match the schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "429": { "description": "Rate limit or billing limit exceeded", "content": { @@ -2489,7 +2556,7 @@ "Contacts" ], "summary": "Create or update a contact by email", - "description": "Idempotent contact upsert keyed by email. Returns 201 on first create, 200 on subsequent updates.", + "description": "Idempotent contact upsert keyed by email. Always answers 200 — the create-vs-update distinction is not signalled via status code.", "security": [ { "ApiKeyAuth": [] @@ -2510,7 +2577,7 @@ }, "responses": { "200": { - "description": "Existing contact updated", + "description": "Contact created or updated", "content": { "application/json": { "schema": { @@ -2534,33 +2601,18 @@ } } }, - "201": { - "description": "Contact created", + "400": { + "description": "Validation error", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "enum": [ - true - ] - }, - "data": { - "$ref": "#/components/schemas/Contact" - } - }, - "required": [ - "success", - "data" - ] + "$ref": "#/components/schemas/Error" } } } }, - "400": { - "description": "Validation error", + "401": { + "description": "Unauthorized — missing or invalid auth", "content": { "application/json": { "schema": { @@ -2569,8 +2621,8 @@ } } }, - "401": { - "description": "Unauthorized — missing or invalid auth", + "403": { + "description": "Forbidden — insufficient permissions or project disabled", "content": { "application/json": { "schema": { @@ -2579,8 +2631,8 @@ } } }, - "403": { - "description": "Forbidden — insufficient permissions or project disabled", + "422": { + "description": "Validation failed — request body or query parameters did not match the schema", "content": { "application/json": { "schema": { @@ -2725,6 +2777,16 @@ } } }, + "422": { + "description": "Validation failed — request body or query parameters did not match the schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "429": { "description": "Rate limit or billing limit exceeded", "content": { @@ -2836,6 +2898,16 @@ } } }, + "422": { + "description": "Validation failed — request body or query parameters did not match the schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "429": { "description": "Rate limit or billing limit exceeded", "content": { @@ -3075,6 +3147,16 @@ } } }, + "422": { + "description": "Validation failed — request body or query parameters did not match the schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "429": { "description": "Rate limit or billing limit exceeded", "content": { @@ -3103,6 +3185,7 @@ "Contacts" ], "summary": "Delete a contact", + "description": "Hard-delete a contact. Answers 200 with `{ success, data: { id } }` (pre-seam this was 204 No Content).", "security": [ { "ApiKeyAuth": [] @@ -3123,8 +3206,15 @@ } ], "responses": { - "204": { - "description": "Contact deleted" + "200": { + "description": "Contact deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IdResponse" + } + } + } }, "400": { "description": "Validation error", @@ -3823,7 +3913,7 @@ "Templates" ], "summary": "List templates", - "description": "Page-paginated list of templates. Use `search` for full-text-ish filtering on name/description/subject.", + "description": "Cursor-paginated list of templates. Use `search` for full-text-ish filtering on name/description/subject.", "security": [ { "ApiKeyAuth": [] @@ -3837,21 +3927,20 @@ "schema": { "type": "integer", "minimum": 1, - "default": 1 + "maximum": 100, + "default": 20 }, "required": false, - "name": "page", + "name": "limit", "in": "query" }, { "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 20 + "type": "string", + "minLength": 1 }, "required": false, - "name": "pageSize", + "name": "cursor", "in": "query" }, { @@ -3917,6 +4006,16 @@ } } }, + "422": { + "description": "Validation failed — request body or query parameters did not match the schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "429": { "description": "Rate limit or billing limit exceeded", "content": { @@ -4020,6 +4119,16 @@ } } }, + "422": { + "description": "Validation failed — request body or query parameters did not match the schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "429": { "description": "Rate limit or billing limit exceeded", "content": { @@ -4259,6 +4368,16 @@ } } }, + "422": { + "description": "Validation failed — request body or query parameters did not match the schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "429": { "description": "Rate limit or billing limit exceeded", "content": { @@ -4287,7 +4406,7 @@ "Templates" ], "summary": "Delete a template", - "description": "Refuses with 409 if the template is still attached to a workflow step or active campaign.", + "description": "Answers 200 with `{ success, data: { id } }` (pre-seam this was 204 No Content). Refuses with 409 if the template is still attached to a workflow step or active campaign.", "security": [ { "ApiKeyAuth": [] @@ -4308,8 +4427,15 @@ } ], "responses": { - "204": { - "description": "Template deleted" + "200": { + "description": "Template deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IdResponse" + } + } + } }, "400": { "description": "Validation error", diff --git a/tests/test_client.py b/tests/test_client.py index 0120adc..04a5fbf 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -110,6 +110,31 @@ def test_maps_400_to_validation_error_with_envelope(): assert str(err) == "bad input" +def test_maps_422_to_validation_error_with_details(): + # Migrated routes return 422 VALIDATION_ERROR (with error.details.errors) + # for invalid input; it must map to SendlyValidationError like a 400. + rec = Recorder( + json_response( + 422, + { + "success": False, + "error": { + "message": "Invalid body", + "code": "VALIDATION_ERROR", + "details": {"errors": [{"path": "email", "message": "required"}]}, + }, + }, + ) + ) + client = make_client(rec) + with pytest.raises(SendlyValidationError) as excinfo: + client.request(method="POST", path="/api/contacts", body={}) + err = excinfo.value + assert err.status_code == 422 + assert err.error_code == "VALIDATION_ERROR" + assert err.body["error"]["details"]["errors"][0]["path"] == "email" + + def test_maps_401_to_authentication_error(): rec = Recorder(json_response(401, {"error": {"message": "no key", "code": "unauthorized"}})) client = make_client(rec) diff --git a/tests/test_contacts.py b/tests/test_contacts.py index acfaa37..eb95afc 100644 --- a/tests/test_contacts.py +++ b/tests/test_contacts.py @@ -6,8 +6,8 @@ import pytest -from sendly import SendlyNotFoundError -from support import Recorder, empty_response, json_response, make_client +from sendly import SendlyNotFoundError, SendlyValidationError +from support import Recorder, json_response, make_client def test_create_posts_and_unwraps_data(): @@ -46,13 +46,30 @@ def test_update_patches_contact_path(): assert rec.request.method == "PATCH" -def test_delete_sends_delete_and_resolves_on_204(): - rec = Recorder(empty_response(204)) +def test_delete_sends_delete_and_discards_200_id_body(): + # The API returns 200 with {success, data: {id}}; the SDK discards it -> None. + rec = Recorder(json_response(200, {"success": True, "data": {"id": "c_4"}})) client = make_client(rec) assert client.contacts.delete("c_4") is None assert rec.request.method == "DELETE" +def test_bulk_delete_raises_validation_error_on_422(): + # Bulk ops on an unresolved project now return 422 VALIDATION_ERROR (was NO_PROJECT). + rec = Recorder( + json_response( + 422, + { + "success": False, + "error": {"message": "No project", "code": "VALIDATION_ERROR"}, + }, + ) + ) + client = make_client(rec) + with pytest.raises(SendlyValidationError): + client.contacts.bulk_delete({"emails": ["a@b.com"]}) + + def test_get_raises_not_found_on_404(): rec = Recorder( json_response(404, {"error": {"message": "no such contact", "code": "not_found"}}) diff --git a/tests/test_templates.py b/tests/test_templates.py index 794975f..ae4d6e4 100644 --- a/tests/test_templates.py +++ b/tests/test_templates.py @@ -5,7 +5,7 @@ import pytest from sendly import SendlyConflictError -from support import Recorder, empty_response, json_response, make_client +from support import Recorder, json_response, make_client def test_create_posts_templates(): @@ -23,13 +23,14 @@ def test_create_posts_templates(): assert str(rec.request.url) == "http://localhost/api/templates" -def test_list_serializes_page_and_page_size(): - rec = Recorder(json_response(200, {"success": True, "data": {"items": []}})) +def test_list_serializes_cursor_and_limit(): + rec = Recorder(json_response(200, {"success": True, "data": {"data": [], "total": 0}})) client = make_client(rec) - client.templates.list({"page": 2, "pageSize": 25}) + client.templates.list({"limit": 25, "cursor": "t_50", "type": "MARKETING"}) url = str(rec.request.url) - assert "page=2" in url - assert "pageSize=25" in url + assert "limit=25" in url + assert "cursor=t_50" in url + assert "type=MARKETING" in url def test_update_patches_template(): @@ -39,8 +40,9 @@ def test_update_patches_template(): assert rec.request.method == "PATCH" -def test_delete_resolves_on_204(): - rec = Recorder(empty_response(204)) +def test_delete_discards_200_id_body(): + # The API returns 200 with {success, data: {id}}; the SDK discards it -> None. + rec = Recorder(json_response(200, {"success": True, "data": {"id": "t_1"}})) client = make_client(rec) assert client.templates.delete("t_1") is None