From 6c8040b2683b8eaa7fe76b863ff071fc49f5ae3f Mon Sep 17 00:00:00 2001 From: stromek Date: Fri, 16 Oct 2026 14:35:30 +0200 Subject: [PATCH] Fix OpenAPI link, document cursor semantics, 402/403 errors and token type The OpenAPI spec lives at /docs/customer/openapi.json. The lastMessageHash cursor is inclusive and the API has no page size, so paginate_messages.php is rewritten as an incremental sync. README now documents that 402 plan limits and 403 ACTION_FORBIDDEN surface as ApiException, which API key type the SDK needs, and that response meta is not exposed. --- CHANGELOG.md | 12 +++++++ README.md | 62 +++++++++++++++++++++++++++++--- examples/README.md | 2 +- examples/paginate_messages.php | 65 ++++++++++++++++++++-------------- 4 files changed, 109 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fb9fa9..06f5851 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- README: OpenAPI spec link now points to `/docs/customer/openapi.json` (was `swagger.json`). +- `examples/paginate_messages.php` rewritten as incremental sync — the `lastMessageHash` cursor is inclusive and the API has no page size. + +### Documentation + +- README: how the `lastMessageHash` cursor behaves (oldest-first, inclusive, no page size). +- README: 402 plan-limit and 403 `ACTION_FORBIDDEN` errors surface as `ApiException`; branch on `getApiCode()`. +- README: required token type (`customer` / `both`, server-side only) and where to create it. +- README: known limitation — response `meta` (warnings, `meta.updated`) is not exposed. + ## [0.1.0] - 2026-07-17 ### Added diff --git a/README.md b/README.md index 31acb8c..2ba7301 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,8 @@ composer require stromcom/customer-sdk ## API access & documentation -- **Reference docs:** -- **OpenAPI spec:** +- **Reference docs (Swagger UI):** +- **OpenAPI spec:** API access is gated by your STROMCOM subscription — only plans that explicitly include API access can issue Customer API tokens and call @@ -23,6 +23,11 @@ the endpoints below. If your token returns `401 Unauthorized` or your project's `allowAPI` flag is `false`, check your current plan in the STROMCOM console or contact support to upgrade. +The token must be an API key of type **`customer`** (or `both`), created +in the customer panel under *Project → Credentials* +(`/customer/project/{id}/credentials/`). It grants full access to the +project — use it **server-side only**, never in browser or mobile code. + ## Quick start ```php @@ -108,7 +113,7 @@ covering each area: | [`users_lifecycle.php`](examples/users_lifecycle.php) | Create → fetch → update → delete → restore. | | [`thread_with_messages.php`](examples/thread_with_messages.php) | Thread + messages + attachments in one call. | | [`create_thread_and_message.php`](examples/create_thread_and_message.php) | Create/upsert a thread with an initial message, update it, send a follow-up message. | -| [`paginate_messages.php`](examples/paginate_messages.php) | Cursor pagination via `lastMessageHash`. | +| [`paginate_messages.php`](examples/paginate_messages.php) | Incremental sync via the `lastMessageHash` cursor. | | [`download_attachment.php`](examples/download_attachment.php) | Resolve and save attachments to disk. | | [`message_reactions.php`](examples/message_reactions.php) | List emoji reactions on a message, aggregated by code. | | [`error_handling.php`](examples/error_handling.php) | Catch the typed exception hierarchy. | @@ -168,6 +173,20 @@ AttachmentDownload $stromcom->messages()->attachmentDownload(string $threa list $stromcom->messages()->reactions(string $threadCode, string $messageHash); ``` +### Message cursor (`lastMessageHash`) + +`messages()->list($threadCode, $lastMessageHash)` is not page-based: + +- messages are returned **oldest-first**; +- the cursor is **inclusive** — the first returned message is the cursor + message itself; +- there is **no page size** — the server returns every message from the + cursor onward (limited only by your plan's history window). + +Use it for incremental sync: store the hash of the newest message you +have, fetch from it next time and drop the first item when it equals the +cursor. See [`examples/paginate_messages.php`](examples/paginate_messages.php). + ## Configuration ```php @@ -197,11 +216,11 @@ All SDK exceptions extend `Stromcom\Sdk\Exception\StromcomException`. | `AuthenticationException` | 401 — invalid / missing token | | `NotFoundException` | 404 — resource not found | | `ValidationException` | 400 / 422 — invalid input | -| `ConflictException` | 409 — e.g. user code already exists, limit reached | +| `ConflictException` | 409 — e.g. user code already exists | | `RateLimitException` | 429 — slow down | | `ServerException` | 5xx | | `TransportException` | network failure / non-JSON response | -| `ApiException` | catch-all base for any other API error | +| `ApiException` | base class; thrown as-is for any other status, notably 402 and 403 (see below) | Every API exception exposes: @@ -216,9 +235,42 @@ try { } ``` +Plan limits (**402**) and forbidden actions (**403**) have no dedicated +subclass — they surface as a plain `ApiException`. Catch the specific +exceptions first, then `ApiException`, and branch on `getApiCode()`: + +| Status | `getApiCode()` | +|---|---| +| 402 | `USER_LIMIT_REACHED`, `THREAD_LIMIT_REACHED`, `MESSAGE_LIMIT_REACHED`, `STORAGE_LIMIT_REACHED`, `MESSAGE_STORAGE_LIMIT_REACHED`, `WEBHOOK_LIMIT_REACHED` | +| 403 | `ACTION_FORBIDDEN` | + +```php +use Stromcom\Sdk\Exception\ApiException; +use Stromcom\Sdk\Exception\ValidationException; + +try { + $stromcom->messages()->create('order-1234', $payload); +} catch (ValidationException $e) { + // fix the payload +} catch (ApiException $e) { + match ($e->getApiCode()) { + 'MESSAGE_LIMIT_REACHED', 'STORAGE_LIMIT_REACHED' => notifyAdminToUpgrade($e), + 'ACTION_FORBIDDEN' => logForbidden($e), + default => throw $e, + }; +} +``` + A worked example of catching every variant lives in [`examples/error_handling.php`](examples/error_handling.php). +## Known limitations + +- The SDK returns only the `data` part of the API response envelope. The + `meta` part (e.g. `meta.warnings`, `meta.updated`) is dropped and is not + reachable through the SDK. If you need it, call the API directly or wrap + your own `HttpClientInterface` to inspect the raw response. + ## Requirements - PHP **8.3** or **8.4** diff --git a/examples/README.md b/examples/README.md index 80adc82..386bec7 100644 --- a/examples/README.md +++ b/examples/README.md @@ -19,7 +19,7 @@ php examples/quickstart.php | [`users_lifecycle.php`](users_lifecycle.php) | Full user lifecycle: create → fetch → update → delete → restore. | | [`thread_with_messages.php`](thread_with_messages.php) | Single-call fetch of a thread, its messages, and grouped attachments. | | [`create_thread_and_message.php`](create_thread_and_message.php) | Create/upsert a thread with an initial message, update it, send a follow-up message. | -| [`paginate_messages.php`](paginate_messages.php) | Walk every message in a thread via the `lastMessageHash` cursor. | +| [`paginate_messages.php`](paginate_messages.php) | Incremental sync of new messages via the inclusive `lastMessageHash` cursor. | | [`download_attachment.php`](download_attachment.php) | Resolve attachment metadata, fetch a signed URL, save to disk. | | [`message_reactions.php`](message_reactions.php) | List emoji reactions on a message, aggregated by code. | | [`error_handling.php`](error_handling.php) | Catch the typed exception hierarchy (`NotFound`, `Validation`, …). | diff --git a/examples/paginate_messages.php b/examples/paginate_messages.php index 96d9a1c..f226a48 100644 --- a/examples/paginate_messages.php +++ b/examples/paginate_messages.php @@ -2,52 +2,65 @@ declare(strict_types=1); /** - * Paginate through every message in a thread. + * Incremental sync of a thread's messages via the `lastMessageHash` cursor. * - * The `lastMessageHash` query parameter is a cursor: pass the hash of - * the last message you've already seen to get the next batch. The - * Customer API returns messages in chronological order; the loop - * below stops when an empty page comes back. + * How the cursor works on the Customer API: + * - messages come back oldest-first; + * - `lastMessageHash` is INCLUSIVE — the first returned item is the + * cursor message itself; + * - there is no page size — the server returns every message from the + * cursor onward (bounded only by your plan's history window). + * + * So there is nothing to "page through": fetch once without a cursor, + * remember the hash of the newest message, and on the next sync fetch + * from that hash and drop the first item (you already have it). * * Run with: * STROMCOM_TOKEN=… STROMCOM_THREAD=order-1234 php examples/paginate_messages.php + * STROMCOM_TOKEN=… STROMCOM_THREAD=order-1234 STROMCOM_CURSOR= php examples/paginate_messages.php */ require __DIR__ . '/../vendor/autoload.php'; use Stromcom\Sdk\Client; +use Stromcom\Sdk\Configuration; +use Stromcom\Sdk\Model\Message; $token = getenv('STROMCOM_TOKEN') ?: ''; $threadCode = getenv('STROMCOM_THREAD') ?: ''; +$baseUrl = getenv('STROMCOM_BASE_URL') ?: 'https://www.stromcom.cz/api/customer/v1/'; if ($token === '' || $threadCode === '') { fwrite(STDERR, "Set STROMCOM_TOKEN and STROMCOM_THREAD.\n"); exit(1); } -$stromcom = new Client($token); - -$cursor = null; -$total = 0; +$stromcom = new Client(new Configuration(token: $token, baseUrl: $baseUrl)); -do { - $page = $stromcom->messages()->list($threadCode, lastMessageHash: $cursor); - $count = count($page->messages); - $total += $count; +/** + * Return messages newer than $cursor (all messages when $cursor is null). + * + * @return list + */ +function fetchNewMessages(Client $stromcom, string $threadCode, ?string $cursor): array { + $messages = $stromcom->messages()->list($threadCode, lastMessageHash: $cursor)->messages; - foreach ($page->messages as $msg) { - printf("%-12s %s\n", $msg->hash, strip_tags($msg->message)); + if ($cursor !== null && $messages !== [] && $messages[0]->hash === $cursor) { + array_shift($messages); } - if ($count === 0) { - break; - } + return $messages; +} - $cursor = $page->messages[$count - 1]->hash; +// In a real integration the cursor lives in your database, per thread. +$cursor = getenv('STROMCOM_CURSOR') ?: null; - // First-page-only single fetch when the API serves the whole thread - // in one shot — guard against an infinite loop on small threads. - if ($count < 50) { - break; - } -} while (true); +$newMessages = fetchNewMessages($stromcom, $threadCode, $cursor); + +foreach ($newMessages as $message) { + printf("%-12s %s\n", $message->hash, strip_tags($message->message)); +} + +if ($newMessages !== []) { + $cursor = $newMessages[array_key_last($newMessages)]->hash; +} -printf("\nTotal messages walked: %d\n", $total); +printf("\nNew messages: %d\nNext cursor: %s\n", count($newMessages), $cursor ?? '(none)');