Add the Kotlin SDK, generated from the Smithy model - #198
Merged
Merged
Conversation
This was referenced Sep 15, 2026
robzolkos
force-pushed
the
feature/kotlin-sdk
branch
2 times, most recently
from
September 15, 2026 18:32
dab24f3 to
065159e
Compare
robzolkos
force-pushed
the
feature/kotlin-sdk
branch
from
September 15, 2026 23:05
46bc103 to
9cfd5cd
Compare
robzolkos
force-pushed
the
feature/kotlin-sdk
branch
from
September 16, 2026 00:35
2f82380 to
8b7e4d7
Compare
robzolkos
added a commit
that referenced
this pull request
Sep 16, 2026
… credentials it could not renew (#205) Client::refresh_credentials coalesced the 401s a stale credential earned on the count of refreshes that had renewed it: a request signed before the last refresh was resent on the new credentials rather than refreshing again. A refresh that answered false did not move that count, so every other request already signed under the same credentials — queued on the refresh turn behind the first — found the count where it had left it and asked the provider again, in turn. During an outage at the token's issuer that was one token-endpoint call per stale request rather than one per set of credentials, and a provider holding a rotating refresh token spent it several times over. The doc comment said as much — "the next 401 asks again rather than trusting a failure" — which is right for a request signed after the failure, whose 401 is news, and wrong for one signed before it, whose credentials are the very ones the refresh just failed to renew. A request is now signed under a Generation: the count of refreshes that renewed the credentials and the count of refreshes that ran to an answer at all, both read under the signing lock so no refresh moves either between the two. On a 401 the refresh task takes its turn and reads them again. If a refresh renewed the credentials since the signing, the request is resent without refreshing, as before; if one ran and did not, that failure is this request's answer and the provider is not asked; a caller gone before its turn still has no refresh started for it; otherwise the provider is asked, and the counts move with its answer. A request signed after the failure carries the new run count, so its 401 refreshes again. The Kotlin client made the same change in #198. The public API is untouched. Tests send two requests signed with the same stale credentials, the second answered only once the first has failed, and see one provider call, no resend and a 401 each, then a third request on the same client refreshed again; hold the failing refresh open so a second 401 comes back while it runs, and see it wait its turn and share the answer; and see four 401s arriving together cost one provider call rather than four. The tokio dev-dependency gains test-util so the in-flight case can hold the refresh on paused time.
robzolkos
force-pushed
the
feature/kotlin-sdk
branch
from
September 16, 2026 02:13
27dca34 to
9b5520b
Compare
robzolkos
marked this pull request as ready for review
September 16, 2026 02:22
Copilot stopped reviewing on behalf of
robzolkos due to an error
September 16, 2026 02:43
This was referenced Sep 16, 2026
robzolkos
force-pushed
the
feature/kotlin-sdk
branch
from
September 16, 2026 15:54
8f1bd7b to
36d974c
Compare
robzolkos
added a commit
that referenced
this pull request
Sep 16, 2026
…d ask what it would sign with before spending a refresh (#211) * Take a token the provider renews on its own for the renewal it is, and ask what it would sign with before spending a refresh A TokenProvider that renews ahead of expiry hands access_token a new token without being asked to refresh, which is what OAuth libraries do. The client counted only the refreshes it asked for, so a 401 on the old token, arriving after the provider had already rotated to the new one, read as a 401 on current credentials: the refresh task called refresh again, over the top of a token the provider had just been issued. With a rotating refresh token that second use is a replay, and an issuer that treats a replay as theft revokes the whole grant. Kotlin fixed this on #198 and Go on #206. The SDK's own BearerAuth now marks the request it signs, and the client reads the bearer it put on: one other than the last signed with is a renewal the provider made of its own accord, and moves the counts as a refresh would, so a 401 on the old token is resent on the new one rather than refreshed. Only the SDK's own strategy is read this way — a strategy of the caller's may sign every request differently, and is asked to refresh as it always was. Before a refresh task asks the provider to refresh, it asks what the provider would sign with now, by signing a request that goes nowhere; a token other than the one the 401 came back on is a renewal already made, and the request is resent with it without the refresh being spent. Only a token the provider would still sign with is refreshed. After either kind of renewal the comparison is reset, so the first signing after a refresh is not counted a second time. Signings are serialised: the token is taken from the provider, compared with the last, and the counts read under one mutex, taken inside the read half of the refresh lock and always in that order. Copilot found on the Go port that concurrent signers let a token issued first be recorded second, so a request that came out with the newer token read the counts of the older and a genuine 401 on the newer was taken for one already answered; and that a request signed under a newer generation could join a refresh in flight for an older one and take its answer. The mutex rules out the first. The second cannot happen here: every 401 gets a task and a turn of its own, and each reads the counts against the generation its own request was signed under only once it holds the turn, so there is no refresh in flight to join. The public API is untouched. Tests sign two requests t0 and t1, hold both at the server, answer the t0 one 401 and see it resent with t1 and refresh never called, then a 401 on t1 refreshed once; send one request on t0 with the provider rotating to t1 before anything else is signed, and see the 401 resent with t1 without a refresh; hand the first of two concurrent signers its t0 slowly and the second its t1 at once, and see t0 recorded first and the 401 on t1 refreshed exactly once; and sign every request differently from a strategy of the caller's, and see a 401 on the first still ask it to refresh, once. * Assert the ordering test's credentials as a set, since only the signings are serialised The test that hands the first of two concurrent signers its t0 slowly and the second its t1 at once asserted the order the three credentials reached the server in. The mutex serialises the signings, not the sends after them: once the t0 signer lets go, the t1 task can reach the transport first, and on the multi-thread runtime whether it does is the scheduler's to decide. What the test guards is that the genuine 401 on t1 is refreshed rather than resent, which the refresh count and the t1 call's success already say, so the credentials are now compared as a set. * Know the SDK's own bearer strategy by how the client was built, and take a provider that cannot hand over a token for the refresh failing The bearer a signing put on was read for a renewal whenever the request carried the mark BearerAuth leaves. BearerAuth is public, so a strategy of the caller's that signs through it and then rewrites the header — a per-request signature, say — carried the mark too: every signing differed from the last and was counted as a renewal, and a genuine 401 on one of its requests was resent once and surfaced without its refresh ever being asked for. The client now knows it is the SDK's own strategy from being built with token_provider, as Go and Kotlin know it by its type, and nothing on a request says so. Before a refresh is spent the provider is asked what it would sign with. A provider that could not hand over any token was taken as having nothing to say, and its refresh was asked for straight after — so one whose own renewal inside access_token had just failed was made to try again at once. That failure is the refresh's answer now, shared with every request signed under the same credentials as any other failed refresh is, as Kotlin takes it. The ordering test says what it can and cannot promise: with signings serialised it passes whatever the scheduler does, and without them it fails only when the second signer reaches the provider inside the first one's delay, since whether a signer is waiting on the lock is not something a test outside the client can see.
… and TypeScript ones
Re-applies the Kotlin wiring onto the Makefile, scripts, workflows and docs as the TypeScript merge left them: Kotlin joins the release language list through release-languages.sh, the bump and API-version scripts move HeyConfig.kt alongside the TypeScript and Go constants, and the TypeScript tests that stage those scripts in a temp tree stage the Kotlin files too. The generated Recording model picks up main's discriminator documentation.
…ders, page retry policy, hook balance, release verification A hop to another origin now goes out without every header the auth strategy set, whatever it called them, not only the four the SDK knew by name; a Cache-Control: no-store answer is neither held nor left in the cache; a 304 answers the cached body under the headers it was first read with, so a revalidated page keeps its Link and X-Total-Count; a page after the first is read under the first's retry policy rather than the client's own; and every operation and request start the hooks hear gets exactly one end, whether a token provider threw, a refresh threw or the coroutine was cancelled. The release workflow no longer takes a 409 for a finished publish. The publication is laid out in a staging repository first and every file of it is compared with GitHub Packages, before and after publishing: absent means publish, identical means nothing to do, anything else fails naming the files, since a Maven version there can be neither finished nor overwritten. The archives are built reproducibly so that comparison is byte for byte. The Kotlin version targets join the staged, atomic version synchroniser, so a malformed one fails before any Go or TypeScript file is rewritten.
`.github/kotlin-publish-enabled` says false. release-kotlin.yml reads it from the tagged commit: a tag still runs the Kotlin gate and rehearses the publication in the job without packages: write, and nothing goes to GitHub Packages. release-languages.sh reads the same file, so release-github.yml waits for Go and Rust only, and a target from before the switch existed has no Kotlin release to wait for. make release refuses a malformed switch before tagging. The GitHub Packages comparison script is now kotlin-packages-state.sh, leaving kotlin-publish-state.sh for the switch, as typescript-publish-state.sh is.
…cope and drop the query on redirects, persist what a 304 moves The transport now reads each response through Ktor's streaming form and refuses a body the moment it passes its bound, cancelling the channel, rather than after Ktor had held all of it; a producer of 64 MiB is stopped within a few MiB. A redirect that stays on HEY keeps the client's account scope whatever the Location said, and a Location or Link target no longer inherits the query the request went out with, which the scope test turned up. A 304 writes the validator and headers it carried back to the cache entry, and a no-store on a 304 ends the entry. A form write completes only with a 302 or 303; a 301, 307 or 308 is an error naming the status. PostingsService.moveTo refuses an empty selection before it reads the box index. The generator fails generation for a request body it cannot send — another representation, a $ref request body, a schema that is not a $ref — instead of emitting a method that sends nothing.
… raw queries through, bound every parsed document, walk pages without recursion A caller-supplied httpClient built with Ktor's default followRedirects would have followed redirects before the SDK could keep credentials off another origin or the account scope on a hop; the builder now refuses one. The query of a path handed to request() or form() goes out as written rather than decoded and re-emitted, so a %26 stays one parameter. The body bound reads the Accept list the way the Rust crate does, so a form's browser Accept and a +json type are held to the configured cap rather than the fixed one. The HTML reader and the stage parser walk the page with an explicit stack, since its nesting is the server's to decide.
…only transport, root-only close, decode inside the operation, discriminator aliases CalendarEventsService gains updateEvent and updateOccurrence over UpdateCalendarEventParams, EventContent, Countdown and Repeat, ported from the Rust crate, so a caller can send back everything HEY clears on a write; the partial update says plainly what it clears. The builder takes an engine only: a caller's configured HttpClient could carry a retry, a default request, redirect following or response validation that runs ahead of what this client is responsible for. Only the client the builder made closes the transport; one derived with forAccount closes nothing. The operation the hooks hear now runs until the answer is decoded or parsed, so a body that will not read ends it with the error the caller gets. The generator reads discriminatorValues, so a recording is recognised by either spelling of its type. The README names the retry settings as the builder does, and a JVM test checks every setting it names against the builder.
…inator aliases as the other generators do The whole-event update decodes its recording inside the operation the hooks hear and through Response.json, so an answer that is not a recording ends the operation with the HeyException the caller gets; the redirect fallback no longer invents a type. The generator refuses a discriminatorValues entry for a variant the schema does not declare, as the TypeScript generator does, and reads an empty list as the name alone, as the Rust one does, so neither can become a check that is never true.
… bodiless, and let the retry ceiling saturate The page within a change-feed increment is a whole cursor, as HEY issued it: the link can move the since, the version and the size along with the page, and the read that follows sends what HEY issued. A link to it off the origin is refused. A 304, a status the operation takes for nothing there, and the redirect a form takes for its answer carry nothing the SDK reads, so their bodies are not read and cannot be refused for their size. A retry ceiling as high as an Int holds no longer wraps into no retries at all.
…is refused, say what the partial calendar update clears, and refuse a timeout below a millisecond The body buffer never grows past the bound plus the one byte that proves it was passed, so a body at the bound is held once and one past it costs no more than the bound. A failure whose body the client refused to hold is still the error its status maps to — a 422 is a Validation, a 429 a RateLimit with its Retry-After — told why its body is missing, with the too-large flag on every error rather than the generic one alone. The partial calendar update's own documentation now says which fields it clears and points at the whole-event update, and a test holds it to that. A timeout under a millisecond, which Ktor would refuse as zero, is refused up front as a usage error.
…and document Validation as the 422 it is Each credential header's name and each of its values go into the cache partition with their length in front, so two values can never read as one and one as two, and two identities whose headers only happen to spell the same never share an entry. A Retry-After of zero, or a date already past, means now, and is honoured as given rather than replaced by the backoff. The Validation error's documentation names the 422 it is mapped from; a 400 is an Api error with that status, as it is in the Go and Rust SDKs.
…ead stray close tags in linear time, and recognise pasted URLs A request on a closed client, or a refresh awaited across one, is refused as a usage error rather than surfacing as a cancellation the caller never asked for. A token with any control character, not only a line break, is refused before the transport can quote it back, and so is any header a strategy sets that the transport would refuse. The HTML reader keeps a count of open tags, so a page of stray close tags reads in time linear in its length. A Router recognises a pasted HEY URL or path — with or without its .json suffix, query, fragment or trailing slash — as Rust's and Go's do, naming the operation, the resource and the id. The test fixture records requests under a lock, since two in flight run on the engine's own threads.
…to nowhere A Location or Link target that starts at the query or the fragment keeps the base's path in front of it, as RFC 3986 resolves one, where Ktor read it as a path segment: a Link of ?page=2 is the next page, not a cursor. A reference that names nothing resolves to nothing, so a redirect with an empty Location is the answer rather than a re-read of the same path with its query dropped.
…rom the Rust crate CalendarEventsService gains create over CreateCalendarEventParams, encoding the content, attendees, highlight, countdown, repeat, zones and reminders through the same helpers the whole-event update uses. Publications publishes and unpublishes a topic as a form post with a quiet read-back, so the hooks hear one operation. Attachments uploads a file: the blob reserved through the generated operation, then the bytes put to the storage URL unsigned, on another origin, with HEY's own headers. Journal reads and writes a day's entry, answering null for a day without one. Calendars lists with change streams, toggles a calendar's selection and reads the calendar and recording change feeds from a cursor, uncached, with a 409 answered as fullSyncRequired. CalendarPeriods, CalendarTodos and Habits gain the conveniences Go and Rust have. An operation can carry headers of its own and go out unsigned, which the storage put needs.
…extenzion and bulk reply conveniences from the Rust crate Workflows lists an account's workflows, reads a workflow's stages, creates, renames and deletes workflows and stages, and stages, moves and unstages a topic, with the read-back quiet so the hooks hear one operation. World publishes, updates and deletes a post and exports and imports a list's subscribers. TimeTracks creates, renames and deletes a category and exports the tracks as CSV. Snippets, Clips, Collections, Extenzions and BulkReplies gain the form-backed writes the generated surface has no route for. A raw request can go out without its .json suffix, which the autocomplete list and the CSV exports need.
…entity and topic conveniences from the Rust crate Postings gains the rest of its sugar: a walk of a whole change increment, moves to the Feed, the Paper Trail and the Trash, trash for everyone, unmute, spam, box groups, folders and the bubble-up trio, every one over a selection refused when empty and named to the hooks when it is one posting. Clearances screens, rescreens and lists what is pending and screened. Contacts creates and updates a contact, screens one and sets its note, with HEY's 409 read into a ContactConflict. Stickies, Search, Designations, Identity and Topics gain the conveniences Go and Rust have.
…at it is every one the Rust crate does
…ion's own headers the SDK's An unsigned request goes to a URL that authenticates itself, so it goes out as built even when the storage service shares HEY's origin: the account scope is HEY's parameter, not storage's. The header an operation carries of its own is the SDK's to set, for the storage put, rather than a caller's, since a caller's header would need to partition the cache and the Rust crate offers none. A 401 from storage refreshes nothing and a redirect on storage's origin is not signed, each now held by a test.
…two requests once both are in An extenzion's membership is replaced when the field is present at all, so an explicitly empty list goes out as one blank value, which HEY reads as a membership of none, while null sends nothing and leaves it alone. A convenience made of two requests — staging a topic and selecting its stage, publishing a topic and reading the link back — runs both quiet inside one operation, so the hooks hear it end only once the second is in, with the error the caller gets when either fails, rather than as a success after the first.
…end, and end an upload once its bytes are stored A 301 or 302 is only allowed to turn a POST into a GET; a PUT, PATCH or DELETE keeps its method and body, since a GET in its place would report a mutation done that never reached where it was sent. A 303 still says fetch the answer whatever the method, and a 307 or 308 keeps everything. A Retry-After is honoured on any status that earns a resend, a 503's outage window as much as a 429's back-off. An attachment upload runs its reservation and its storage put quiet inside one operation, so the hooks hear it end only once the bytes are stored, with the storage service's refusal when there is one.
… headers out of the cache, and read a negative Retry-After as none A base URL with a query, a userinfo or a fragment is refused at construction: the query would ride along on every request and the rest into every URL the hooks and logs see. What the cache keeps beside a body leaves out the headers the auth strategy signed the request with, should HEY echo them, as it already leaves out the usual suspects. A negative Retry-After is not a wait of nothing but no wait HEY named, so the backoff applies.
… import filename, and ship the MIT notice in every artifact A successful answer the cache cannot hold — no validator, or nothing to hold — has replaced what was held, so the old entry goes rather than being sent back as a validator for a body HEY has moved on from. A subscriber import filename goes into a header line of the multipart body as written, so one with a line break is refused before anything is sent. Every archive the library publishes carries the repository's MIT notice under META-INF, the POM names the licence, and the consumer check holds both to that.
…refuse a blank token as a usage error, name an upload by its route, and time the hooks on a monotonic clock A posting or calendar recording feed answers a stale cursor with a 409, which the convenience hands back as a full-sync answer rather than throwing. The hooks were told otherwise: the request went through the operation lifecycle before the Conflict was caught, so a trace or a failure count saw an operation fail whose caller got an answer. Both feeds now send their request quiet inside an operation of their own, which ends with what the caller gets, as the two-request conveniences already did. A blank static token — what an unset environment variable hands over — threw a bare IllegalArgumentException from the builder, past the sealed hierarchy the README says every failure belongs to. It is a HeyException.Usage now, from the provider and the builder alike, and says nothing of what it was handed. The attachment upload's operation named its resource `direct_upload`; the route, and Go's and Rust's hooks, say `attachment`. The operation is now the reservation's own, so the resource type and everything else about it comes from the model. Operation and request durations subtracted two wall-clock readings, so a clock stepped while a request was out landed in them, negative or long. They are measured on the client's monotonic time source now, which a test can hand over.
…refusal inside its operation, and make a hand-built config a usage error The calendar walks failed at the client's page limit while the postings walk answered what it had read and named the page it did not, and the README promised the failure for all three. The calendar walks now stop as the postings walk does, so a capped answer carries `nextPage` and cannot pass for the end of the feed, which is how Go and Rust read all three feeds too. A time track started while one was running, and a contact write HEY refused, were reworded after the operation had ended, so the hooks heard one error and the caller got another. Both reword inside the operation now, and the calendar changes read runs inside its operation as the recording feed's does. HeyConfig's own checks threw a bare IllegalArgumentException from its constructor and copy(), which the builder wrapped but a caller constructing one did not meet. They are usage errors from the class itself now.
…g inside the operation A storage URL authenticates itself, which is why an upload goes out unsigned and without the account scope even when storage shares HEY's origin. A 307 from storage was scoped all the same: the hop gained filtered_account_id, an edit to a URL the storage service had signed. An unsigned request's hop now goes exactly where it was sent. Trashing a shared topic and publishing a HEY World post each read where HEY sent the caller after the operation had ended, so the hooks heard a success for a call that threw. Both read the redirect inside the operation now, as the other form-backed conveniences do, and the hooks hear the refusal the caller gets.
Setting the first week day or the time format read what HEY stored after the operation had ended, so a stored value the SDK could not read failed the caller while the hooks heard a success. Both reads happen inside the operation now, as every other answer that may not read does. A time format HEY stored that is neither of the two is HEY's answer failing to read, not a mistake of the caller's, so it is an API error rather than a usage error; parsing a format by hand still refuses one as usage. The redirect prose said a hop on HEY always keeps the account scope, and the unsigned request's said such a URL is never on HEY's origin; neither was true once storage may live there. The README now names the upload as the one request the scope stays off.
A 303 turns a PUT into a bodyless GET, and the hop dropped the body's type and length but kept the rest of what described it: an upload's Content-MD5 went out on a GET with nothing for storage to check it against, which is a request a destination may refuse. The hop now drops every content header, as RFC 9110 has a client do when a redirect changes the method, checksums included. A 307 or 308 keeps the body and keeps them all.
…s it was for, and drop every Content header when a hop drops the body A refresh held the signing lock for its whole run, and a stale request found out whether one was in flight by taking that same lock. So a second 401 on the same credentials could not see the refresh already running for them: it waited for the lock instead, and when the refresh had failed — not renewed, or thrown — it started another. An issuer outage cost one refresh per stale request, in a row. A successful refresh hid this by moving the count the second request checked first. The refresh in flight is now guarded by a gate held only for a moment, and the counts a request is signed under say two things: how many refreshes had renewed the credentials, and how many had run at all. A 401 on credentials someone else has since renewed is resent; one on credentials a refresh is renewing joins it; one on credentials a refresh has already failed to renew gets that refresh's answer; and only a request signed after the failure earns a refresh of its own. The strategy is called once per set of credentials, whichever way the refresh ends. A hop that drops the body dropped a named list of the headers that described it, and HEY names the upload's Content-Disposition too. Every Content header goes now, with the few that describe the body under another name.
A cancellation thrown out of a strategy's refresh was read as the client closing, so a timeout the strategy put on its own call was neither recorded nor shared: every other request signed under the same credentials started a refresh of its own, and each got a cancellation it never asked for. Only the refresh coroutine itself being cancelled is the client closing now. Anything else the strategy throws is the refresh's answer, shared like a refusal, and reaches each request as an authentication error carrying it as the cause, so every failure a caller sees is an SDK one, as the README promises. The tests for a shared failure are deterministic now: the second request is answered only once the first has failed, or only once the refresh it is to join is running, and a third request on the same client shows the failure is not held against credentials signed after it.
…r refresh serves a request signed before a failed one The tests for a shared refresh answered the first request's 401 at once. The mock's handler may run inside the request's own coroutine, so on a slow runner that answer let the first request refresh before the second was signed, and the second, signed after the failure, refreshed again as it should. Neither of the first two answers goes back now until both requests have arrived, in the new tests and in the older one for signing and refreshing never interleaving, which was open to the same order. Eight runs pinned to two CPUs pass where two in six failed before. A failed refresh leaves the credentials as they were, so a later refresh of them is a refresh of every request's still signed under them: a 401 arriving while it runs joins it, and is resent if it renews them, rather than being handed the earlier failure. That was already so; a test pins it and the doc says it. CI runs Gradle quietly, which left a failing test unnamed in its log. A failure is now logged at that level, with what it failed on.
The test for a late request being resent after a later refresh told the two requests apart by the order they reached the mock, and answered the second only once a third request had renewed the credentials. The mock's handlers run on whichever thread the engine gives them, so the second request could arrive first; the test then waited on the first request, whose answer waited on a step that came after that wait. The second request is not sent now until the first has arrived, so the first arrival is the first request whatever thread it came in on.
A provider that renews ahead of expiry hands accessToken a new token without being asked to refresh, which is what OAuth libraries do. The client counted only the refreshes it asked for, so a 401 on the old token, arriving after the new one had signed a request, was read as a 401 on current credentials: it refreshed again, over the top of a token the provider had just been issued, which with a rotating refresh token is the one thing an issuer treats as theft. The SDK's own bearer strategy now notices a token that differs from the one it last put on a request and counts it as a renewal, so the 401 is answered by resending, and a 401 on the new token itself is refreshed once, as ever. A strategy of the caller's is not read this way, since it may sign every request differently.
A provider that renews ahead of expiry may have done so with no request signed since, so a 401 on the old token found the counts unmoved and refreshed the new one over the top. Before the SDK's own bearer strategy is asked to refresh, its provider is now asked what it would sign with: a token other than the rejected one is a renewal already made, and the request is resent with it rather than the new token's refresh token being spent. A token the provider would still sign with is refreshed once, as ever.
robzolkos
force-pushed
the
feature/kotlin-sdk
branch
from
September 16, 2026 16:48
36d974c to
122221f
Compare
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.
Summary
Adds a Kotlin SDK under
kotlin/, generated fromopenapi.jsonandbehavior-model.jsonlike the Go, Rust and TypeScript ones, and laid out the way the company-wide basecamp-sdk Kotlin SDK is. Publishing is switched off: merging this and tagging a version still releases Go and Rust only.kotlin/), three modules: thehey-sdklibrary, the generator, and the conformance runner atconformance/runner/kotlinbeside the others. JDK 17, pinned in.mise.toml. Consumers need Kotlin 2.3 or newer, which is the real floor set by the library's and Ktor's metadata;kt-consumer-checkcompiles a consumer against a staged publication at that version to hold it there.kotlin/generatorreads the model andkotlin/generator/names.toml(the twin of the Rust one) and writesgenerated/models(218@Serializabledata classes and typealiases),generated/services(27 services, 131 operations),Routes.kt(idempotency, empty-on statuses, pagination style and retry policy per route) andServiceAccessors.kt.make kt-check-driftfails on a stale tree.HeyClient { accessToken(..) }with basecamp's builder properties,HeyConfigcarryingVERSION/API_VERSION, per-route retry policies the client can only lower, an opt-in ETag cache partitioned by credentials,Pagecursors with a same-origin check,forAccountscoping viafiltered_account_id,SensitiveStringredaction, HTTPS enforcement, bodies bounded while they stream, and hooks that hear each operation end the way the caller does.WorldService.make checkrunskt-check,kt-check-driftandconformance-kt. CI adds aKotlin Testsjob that also runskt-consumer-check.bump-version.shandsync-api-version.shmove the Kotlin constants too.Publishing is off
release-kotlin.ymlpublishes to GitHub Packages only when.github/kotlin-publish-enabledsaystrue. It saysfalse, exactly like the TypeScript switch, sorelease-languages.shleaveskotlinout of the orchestrated release and a tag only rehearses the Kotlin build. Staging compares the artifacts byte-for-byte against anything already on GitHub Packages, and every archive carries the MIT notice with the licence named in the POM. Turning it on is a one-line reviewed change once GitHub Packages access is settled; CONTRIBUTING.md covers it.Conformance fixtures
Kotlin models follow basecamp-sdk's strictness: a required member has no default, so a body missing one fails to decode rather than reading as a zero. That makes Kotlin the strictest reader of a mock body, and fixture bodies in nine files that Go and Rust accepted with required members missing (boxes without
kind/name, recordings withouttype, object-typed responses mocked as[]) now carry what the model requires. All four runners pass: Go 195/195, Rust 195/195, TypeScript 191/191 applicable, Kotlin 195/195.Where it departs from basecamp-sdk, and why
httpClientoption, onlyengine: a plugin on a caller's client would run ahead of the retry policy, the credential handling on redirects, the error mapping and the timeout.Bodyclasses, since HEY's request payloads are nested.Pagewalked withnextPage/eachPage/pagesrather than an auto-aggregatedListResult, since HEY's paginated responses are objects with geared cursors.All four are recorded in AGENTS.md.
Fixes that went back into Go and Rust
Reviewing this SDK turned up defects the Kotlin port had inherited, and each was fixed at the source: #199 and #200 (redirects kept apart from the cache and credentials), #201 and #203 (idempotent PATCH resends, redirect methods,
Retry-After, form writes), #204 (hooks ending a feed read's operation as the caller sees it), #205 and #206 (one refresh per set of credentials, failures shared), #211 and #212 (provider-side token rotation, and the edges of the Go refresh coordination). All are merged, and this branch is rebased on them.Not verified here
make checkon one machine: the Rust half needs more memory than this machine had, so the Kotlin, TypeScript, Go and drift targets were run individually, and CI runs them all.Summary by cubic
Adds Kotlin as the fourth generated HEY SDK; the repository previously shipped only Go, Rust, and TypeScript. It provides a typed
com.basecamp:hey-sdklibrary, but publication remains disabled, so version tags validate and stage releases without publishing Maven artifacts.SDK
make kt-check-driftrejects stale output.HeyClientsupports route-aware retries, shared token refreshes, ETag caching, pagination, account scoping, hooks, redaction, HTTPS enforcement, and bounded streaming reads.nextPageat the page limit.Validation and release
make checkrun the Kotlin build, generator drift check, conformance runner, and Kotlin 2.3+ consumer checks; development requires JDK 17..github/kotlin-publish-enabledisfalse, so the release workflow runs its gate and dry run without publishing to GitHub Packages.make checkand the Kotlin release-action integration remain unverified.Written for commit 122221f. Summary will update on new commits.