Typing What Swagger Couldn't: A Deep Dive into the New Rust SDK

Posted on 6/6/25 by Arnaud, Founding Engineer at Turnkey (follow on X)

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 nested parameters. Activity responses are generic, and include a result 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:

Summarizing our codegen pipeline in one diagram (below):

Standard SDK pipeline: Proto → Swagger → SDK

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:

Polymorphic result

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 use camelCase instead of snake_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 known prost quirk: because prost is meant to parse from and serialize to protobuf, it generates enum fields as i32 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)] to Inner structs which are the result of the polymorphic “oneof” fields in proto files.
  • Add #[serde(default)] to Options and Vecs 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:

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.