Typing What Swagger Couldn't: A Deep Dive into the New Rust SDK
This post is a behind-the-scenes look at the (re)birth of our Rust SDK. Like our TypeScript, Golang, Swift, and Ruby SDKs, it provides a client to interact with the Turnkey API. I’ll start with some background about the Turnkey API and explain why its polymorphism prevents standard codegen pipelines from producing a fully-typed activity client. Then I’ll show how we cracked this problem in the context of the new Rust SDK to guarantee type-safe activity processing. Spoiler: no Swagger involved. Just precise, purpose-built codegen to bring usability to the next tier. Read on for details!
The Turnkey API in a Nutshell
Turnkey exposes a public HTTP API (reference) with a few important characteristics:
- All API requests must be signed: each request must be signed using an end-user’s authenticator (API key, passkey, etc). To sign a request, users produce a signature over the POST body of the request and include it in a
X-Stamp
HTTP header. - Everything is a POST: this is a consequence of the above. Given we want all requests to be signed, and given the signature covers the POST body only, all requests use
POST
for simplicity. This makes Turnkey’s API similar to other “RPC” style APIs, typically found in blockchain nodes (see Bitcoin or Ethereum for example). - Queries and Activities: Turnkey’s API surface is split into two different kinds of requests. Queries do not need to reach secure enclaves and do not perform any sensitive actions. They’re read-only requests. Activities require secure enclave processing, and trigger our activity pipeline for execution. For example: signing requests, resource creation, deletion, or mutation.
- Polymorphic Activity payloads: activity requests include a
type
field that determines the shape of nestedparameters
. Activity responses are generic, and include aresult
object which contains the result of processing a particular activity.
Example activity request for user deletion:
{
"type": "ACTIVITY_TYPE_DELETE_USERS",
"organizationId": "...",
"parameters": { "userIds": ["user-id-to-delete"] }
}
The matching activity response looks like the following (note userIds
in the result field, it is specific to users deletion results):
{
"activity": {
"result": { "userIds": ["deleted-user-id"] }
}
}
Now that we’ve seen what the public API looks like, let’s look at how SDK code is generated today.
Traditional SDK codegen: Proto → Swagger → SDK
All interfaces at Turnkey are defined using proto3
, and we use annotations to expose RPC endpoints over HTTP. For example, here’s the definition of our DELETE_USERS
activity endpoint:
rpc DeleteUsers(external.activity.v1.DeleteUsersRequest) returns (ActivityResponse) {
option (google.api.http) = {
post: "/public/v1/submit/delete_users"
body: "*"
};
}
In the example above, annotations (option (google.api.http)
) define how the DeleteUsers
RPC endpoint is exposed to the outside world: as a POST request, at the URL /public/v1/submit/delete_users
.
These annotations drive the code generation of our API gateway (which uses grpc-gateway) and the generation of our Swagger spec. The DeleteUsers
RPC endpoint has the following Swagger definition (I’ve highlighted the request and response schemas, this will be relevant shortly!):
"/public/v1/submit/delete_users": {
"post": {
"parameters": [{
"name": "body",
"in": "body",
"schema": { "$ref": "#/definitions/v1DeleteUsersRequest" }
}],
"responses": {
"200": {
"schema": { "$ref": "#/definitions/v1ActivityResponse"}
},
},
}
},
This Swagger spec is then used to generate our public SDKs. For example:
- Our TypeScript SDK client (@turnkey/http) uses code to parse the Swagger spec and generate an HTTP client.
- Our Go SDK uses go-swagger to generate the SDK directly
- Our Ruby SDK uses swagger-codegen, a tool which supports generating code from Swagger for many different languages.
- Our Dart SDK client (turnkey_http), similar to our TypeScript SDK, generates code by parsing the swagger spec directly, using swagger_dart_code_generator.
Summarizing our codegen pipeline in one diagram (below):
When Polymorphism Hurts Usability
We’ve established that Turnkey’s Activity responses were both generic and polymorphic. This is an issue because given a response returned by our API, there is not enough information to determine what the type of the result should be. It can be “one of” many different types of activity results. This leads to issues while parsing responses.
Another usability issue is the activity request’s type
field: it’s a bare string, and from a type system point of view, nothing binds it to the polymorphic parameters
field. This leads to issues while making requests requests.
Usability hurdle #1: Manually Setting Activity Types
Taking our Go SDK as an example, the generated method for the DELETE_USERS
activity has the following shape (code link):
type ClientService interface {
// Client method
DeleteUsers(params *DeleteUsersParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*DeleteUsersOK, error)
// ...more methods
}
// Convenience wrapper
type DeleteUsersParams struct {
// Type information taken from Swagger spec
Body *models.DeleteUsersRequest
}
DeleteUsersParams
is a wrapper type around the DeleteUsersRequest
shape. This makes sense because DeleteUsersRequest
is defined in our proto file as the RPC response type, and you can see it being set as the parameters’ schema
attribute in the swagger spec above.
Diving into the definition of DeleteUsersRequest
we spot our first usability issue: the type
field is a string that can be any string from the type system’s perspective, but is expected to be ACTIVITY_TYPE_DELETE_USERS
by our API backend:
type DeleteUsersRequest struct {
Type *string `json:"type"` // MUST be "ACTIVITY_TYPE_DELETE_USERS"
TimestampMs *string `json:"timestampMs"`
OrganizationID *string `json:"organizationId"`
Parameters *DeleteUsersIntent `json:"parameters"`
}
A client calling the Turnkey API has to manually set the type
field to the right value, otherwise our backend will reject the request:
usersClient.DeleteUsers(&users.DeleteUsersParams{
Body: &models.DeleteUsersRequest{
OrganizationID: ptr("my-organization-id"),
Parameters: &models.DeleteUsersIntent{
UserIds: []string{"to-be-deleted-user-id"},
},
TimestampMs: ptr("1234567890"),
// Why isn't this a constant or already set for me?
Type: ptr("ACTIVITY_TYPE_DELETE_USERS"),
},
})
Usability hurdle #2: Activity Response Parsing
Keeping the example we started with (DELETE_USERS
activity), we saw that the Go SDK function returns DeleteUsersOK
. Looking at its definition we see that DeleteUsersOK
is a wrapper type around the ActivityResponse
type, which is the response schema in the swagger spec:
type DeleteUsersOK struct {
Payload *models.ActivityResponse
}
So, there you have it: polymorphism once again! From the type system’s point of view, the result associated with a DELETE_USERS
activity can be any activity result. This is problematic because SDK users must choose which result to pick when accessing activity results. Only one of these is non-nil:
Generating the Rust SDK Client from Protos
Swagger is an amazing tool. It is the default toolchain most engineers reach for when building SDKs (and we use it extensively ourselves) but unfortunately there’s no solid tooling in the Rust ecosystem (see this post).
To generate the Rust SDK client we had to go back to the basics and write Rust code to generate types and client methods. This gave us the flexibility we needed to fix the usability gaps we covered in the previous section.
We wanted developers to be able to call the Turnkey API like this:
let result = client.delete_users(params).await?;
The input should be strongly typed (DeleteUsersParams
) so that users do not have to think about the type
field and cannot compile nonsensical code which mixes different activity types and parameters. The result
should be handed back to the caller as a properly typed DeleteUsersResult
value; not a serde_json::Value
; and not a catch-all enum!
To do this we’ve implemented code generation from proto files, in 3 steps:
- Type generation which gives us the basic structs and enums to work with.
- HTTP client method generation which solves the polymorphism issues we’ve seen.
- Rust code manipulation to make the generated types parse from and serialize to JSON natively. This is crucial because our HTTP API accepts and returns JSON.
Generating Base Rust Types
This is the easiest step: generating base Rust types can be done directly with tonic_build
, which accepts a set of proto files and generates Rust types. You can see this in action here. Note the use of Serde derive
attributes: this ensures our structs and enums can correctly serialize to JSON and be parsed from JSON.
#[derive(::serde::Serialize, ::serde::Deserialize)]
enables JSON serialization and parsing#[serde(rename_all = \"camelCase\")]
ensures that fields usecamelCase
instead ofsnake_case
, which is standard casing in proto field names. This is relevant when serializing and parsing to and from JSON.
Now onto the trickier part: creating the HTTP client and its methods.
Type Injection in HTTP Client Methods
Generating HTTP client methods means templating Rust code which calls all of our endpoints. The goal? Produce something like this:
async pub fn delete_users(&self, params: DeleteUsersParams)
-> Result<DeleteUsersResult, ErrorType> {
// assemble payload
body := make_body(params);
// make the request
let response := self.http
.post("/v1/public/activity/delete_users")
.post_body(body)
.await?;
// parse the response
serde_json::from_str(response.text)?;
}
The first piece of information we need is the route (in the example above: /v1/public/activity/delete_users
). Unfortunately this is hard because tonic_build
(and prost
, the underlying library used by tonic_build
) does not support parsing HTTP annotations from proto files, and in our case this is where the route definition lives. We had to parse these annotations manually with regular expressions, as seen here. We traverse the proto file until we can parse routes (let route = &http_caps[1]
).
The other pieces of information which need to be specified for each method are the activity parameters and result types (in the example above: DeleteUsersParams
and DeleteUsersResult
). Because this mapping is missing we had to specify it ourselves (see activities.json). This mapping declares, for a given route, the expected activity params, result, and type. For example, the mapping entry relevant to our DELETE_USERS
activity:
"/public/v1/submit/delete_users": {
"type": "ACTIVITY_TYPE_DELETE_USERS",
"intentType": "DeleteUsersIntent",
"resultType": "DeleteUsersResult"
},
This mapping is used to template the right types in the HTTP client methods based on the parsed route from earlier. See this in action here.
Rust Code Transformations
The last step of our code generation process is a final polishing step to make our Rust SDK feel native. We use syn, a Rust source parser, to do the following:
- Transform enum fields from
i32
back to real enums. This is a knownprost
quirk: becauseprost
is meant to parse from and serialize to protobuf, it generates enum fields asi32
and it’s a pragmatic choice, but it’s not a great choice for our SDK where we serialize to and parse from JSON. - Rename enum variants to
UPPER_SNAKE_CASE
to make JSON serialization and parsing work correctly, using#[serde(rename = "<ENUM_NAME_VARIANT>")]
- Add
#[serde(flatten)]
toInner
structs which are the result of the polymorphic “oneof” fields in proto files. - Add
#[serde(default)]
toOption
s andVec
s to make sure missing lists and optional fields can be parsed correctly.
Needless to say this wasn’t as straightforward as we would’ve wanted. If you feel like diving in the code, head to transform.rs 👀
The result is worth it: we have a Rust SDK which feels native and just works. This example shows everything in action. Native enums, typed inputs, and typed results:
// `create_wallet_result` is a CreateWalletResult type, directly!
let create_wallet_result = client
.create_wallet(
organization_id,
client.current_timestamp(),
CreateWalletIntent {
wallet_name: "New wallet".to_string(),
accounts: vec![WalletAccountParams {
// This enum feels native, no "as i32" or bare string
curve: Curve::Secp256k1,
path_format: PathFormat::Bip32,
path: "m/44'/60'/0'/0".to_string(),
address_format: AddressFormat::Ethereum,
}],
},
)
.await?;
Bonus: Crypto Batteries Included
Turnkey writes secure enclave software in Rust, and this includes core cryptographic primitives such as secure channels, key generation or signing. On top of a top-notch HTTP client to interact with the Turnkey API, our new Rust SDK comes with:
turnkey_enclave_encrypt
, a crate with utilities to decrypt and encrypt data from and to Turnkey secure enclavesturnkey_proofs
, a crate to fetch and verify boot proofs. This is still experimental, but is the start of end-to-end verifiability, as outlined in our whitepaper. Stay tuned for more in that space!
Conclusion
Polymorphic activity requests and responses break every off‑the‑shelf codegen tool because they lack the necessary type information. Rather than accept a new partially‑typed SDK—and the runtime bugs that follow we built a Rust‑native codegen pipeline that starts with our canonical proto definitions, injects the necessary type information, and surgically patches prost
output to make the final surface feels like hand‑written Rust. The payoff?
- Compile‑time guarantees: developers can no longer mix up activity types, forget the right
type
string, or encounter errors because of JSON parsing gone wrong. The Rust compiler enforces the contract. - Ergonomic client code: a single async call returns a concrete
Result
, not a generic wrapper that requires manual pattern‑matching or deserializing. - Shared cryptography primitives: because the SDK lives in the same language we use inside our secure enclaves, we can open-source battle‑tested building blocks (
turnkey_enclave_encrypt
,turnkey_proofs
) and guarantee consistency.
Typed clients are not just syntactic sugar; they eliminate an entire class of integration bugs and let users focus on what they’re building instead of how to call the Turnkey API safely. If you’re ready to give it a spin, install the crate (cargo add turnkey_client
), skim the examples, and tell us if you hit any sharp edges by opening a Github issue—we’ll keep sanding them down.