Blog

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

Developer
·
·
Arnaud Brousseau, Founding Engineer

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"`
}

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 designed to parse from and serialize to protobuf, it represents enums as i32. That works for protobuf but is not ideal for our SDK, which serializes to and parses from JSON.
  • Rename enum variants to UPPER_SNAKE_CASE to ensure JSON serialization and parsing work correctly, using the Serde rename attribute.
  • Add the Serde flatten attribute to inner structs that result from polymorphic oneof fields in proto files.
  • Add the Serde default attribute to Option fields and Vec fields so 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:

    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 correct type string, or run into errors from incorrect JSON parsing. The Rust compiler enforces the contract.
    • Ergonomic client code: A single async call returns a concrete Result instead of a generic wrapper that requires manual pattern matching or deserialization.
    • Shared cryptography primitives: Because the SDK uses the same language we run inside our secure enclaves, we can open source battle-tested building blocks such as turnkey_enclave_encrypt and turnkey_proofs while maintaining 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.

    Related articles

    Turnkey’s 3 phases of secure software development

    Explore Turnkey’s software development process. See how each stage turns source code into a cryptographically verifiable artifact that is built, approved, and attested.

    Developer
    No items found.
    October 23, 2025

    Create sub-orgs with resources and policies using Turnkey

    Explore how app builders can use Turnkey to implement an account with multiple wallets and granular policy controls.

    Developer
    No items found.
    July 8, 2025