feat: purpose, folders, preparation status and idempotent sends (TPL-2543, TPL-2539) - #10
Merged
Merged
Conversation
…2543, TPL-2539) Brings this client level with lettr-php 2.7.0. Everything is additive. `folders().list()` is the one that unblocks the rest: nothing in the SDK ever returned a folder id, so `CreateTemplateOptions.folderId()` could only be used by hardcoding an integer read out of an app URL. Read-only, because deleting a folder moves or deletes the templates inside it. `TemplatePurpose` and `TemplatePreparationStatus` as Gson-annotated enums, on create, on every template response and as list filters. Both getters default rather than returning null when the API omits the field - transactional and ready - because on a deployment that predates them every template with HTML was simply usable, and a null would push the decision onto every caller. `isSettled()` rather than `isReady()`: after an update the previous render stays live, so a pending template is still sendable while serving the old content, and a name like `isReady()` invites wiring it into a send guard that blocks legitimate sends. `ListTemplatesParams.folderId()` reconciles a bulk import in one call instead of a detail call per template, each dragging the full HTML payload against the same rate limit. Idempotency arrives as a second `send()` overload rather than a changed signature, so existing single-argument calls are untouched. The key is caller-supplied: the SDK does not retry, so the retry belongs to the caller, and a key minted inside `send()` would differ on every attempt and protect nothing. A malformed key throws before any request goes out. The two 409s become separate exceptions under `LettrApiException` because one is safe to retry with the same key and the other fails forever. That mapping lives in `handleErrorResponse` rather than in the service - unlike `ContactAlreadyExistsException`, which needs the submitted email and so has to be rewrapped where it is known - because `Retry-After` is a transport concern the service never sees. `HttpClient` gained `postWithHeaders()` and an `ApiResponse<T>` holder for the one place a response header carries meaning; the existing methods are unchanged and `execute()` now delegates to it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016uJ8Gsfq5iEPsJv6GxHUgU
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Release 1.6.0. Fourth of the five in TPL-2543/TPL-2539, level with lettr-php 2.7.0, node, python and go. Everything is additive — code written against 1.5.1 keeps compiling and sends identical requests.
lettr.folders().list()The one that unblocks the rest.
CreateTemplateOptions.folderId()has existed for ages, but nothing in the SDK ever returned a folder id — so a caller either omitted it and accepted whichever folder the API picked, or hardcoded an integer read out of an app URL.Read-only: deleting a folder moves or deletes the templates inside it, so that stays in the app.
TemplatePurposeandTemplatePreparationStatusGson-annotated enums, on create, on every template response, and as
ListTemplatesParamsfilters.Both getters default rather than returning null —
TRANSACTIONALandREADY— because on an API deployment that predates the fields every template with HTML was simply usable, and a null would push that decision onto every caller. Defaulting toPENDINGwould make an older API look like a stalled queue and hang anything waiting for readiness.isSettled(), notisReady()— deliberately. It answers "is what I sent what will go out", which is not "can I send this": after an update the previous render stays in place, so a pending template is still sendable while serving the old content. A name likeisReady()invites wiring it into a send guard that blocks legitimate sends.ListTemplatesParams.folderId()One
perPage(100)call reconciles a whole bulk import instead of a detail call per template, each dragging the full HTML payload against the same rate limit. A folder outside the resolved project is a 404, not an empty list.Idempotent sends
A second
send()overload, not a changed signature, so existing single-argument calls are untouched.You choose the key; the SDK never generates one. It only works if both attempts use the same value, and the SDK does not retry — one
send()is one HTTP request — so the retry is yours. A malformed key throwsIllegalArgumentExceptionbefore any request is made;IdempotencyKeys.isValid()is public for callers deriving keys from their own ids.Two new exceptions extending
LettrApiException, so existing handlers keep catching them:IdempotencyInProgressException(carriesgetRetryAfter(), retry with the same key) andIdempotencyConflictException(that key was used with a different payload — retrying fails forever).One structural choice worth reviewing
The 409 mapping lives in
handleErrorResponse, not in the service — which differs fromContactAlreadyExistsException, whereAudienceContactscatches and rewraps. The reason: that exception carries the submitted email, which only the service knows, whereasRetry-Afteris a transport concern the service never sees. Putting it in the service would have meant plumbing headers out just to throw.That required threading
HttpHeadersthroughexecute→handleErrorResponse.execute()keeps its signature and delegates to a newexecuteWithHeaders(), andHttpClientgainedpostWithHeaders()plus a smallApiResponse<T>holder — returning headers rather than storing them, so concurrent calls cannot read each other's.Checks
./gradlew buildpasses — compile, tests and checks.New:
FoldersTest(query params, deserialization, the pre-field default, response shape),IdempotencyKeysTest(format, and that validation throws before any request), plus a block inTemplatesTestcovering the folder/purpose filters, both filters absent when unset, enum deserialization, the pre-field defaults,isSettled()semantics, andpurposeserialized only when set.gradle.propertiesbumped to 1.6.0 and a CHANGELOG entry added. Not tagged.🤖 Generated with Claude Code
https://claude.ai/code/session_016uJ8Gsfq5iEPsJv6GxHUgU