Published on

Improving the Svix SDKs With a New Code Generator

Authors
  • avatar
    Name
    Jonas Platte
    Twitter

Cover image

Svix is the enterprise ready webhooks sending service. With Svix, you can build a secure, reliable, and scalable webhook platform in minutes. Looking to send webhooks? Give it a try!

In the past couple months, we've been hard at work completely overhauling the Svix API clients available as part of our various SDKs (among other things). We got rid of a lot of cruft in their internals, improved our processes for adding new APIs, and fixed existing API inconsistencies.

For users of the SDKs: we recommend upgrading. Please check the changelog beforehand as some of the libraries had minor breaking changes in order to make them more consistent and fix some issues.

If you want to jump straight ahead and take a look at the new code generator, you can find it on GitHub. But make sure to come back, there's a lot of information about things like design decisions below, which you won't find in the repository ;)

What was wrong with the SDKs before?

At the beginning of December 2024, we had 8 official SDKs to interact with the Svix API. They all had the same general structure of being split in two:

  • Some sort of internal module or package with generated code - HTTP request-making functions, 'model' types that represent parts of the JSON bodies of requests and responses, and for some SDKs auxiliary types for things like query parameters
  • The actual API for SDK users, with calls into the internal package for making HTTP calls, and re-exports of the model types and often also auxiliary types from that package

This similarity continued in the public API of these SDKs. There was a Svix client object with fields or accessors for proxy objects representing groups of endpoints of our HTTP API - for example: svix.eventType() (or a slight variation thereof) would get you an object through which you could make calls to the operations 'List Event Type', 'Create Event Type', 'Update Event Type', and so on.

When it came to the internal packages though, they were very different across SDK languages. All except one were using the same codegen tool, so I can only guess why its output is so different for different output languages1, but in any case these inconsistencies, as well as a desire to be in control of the public API as much as possible, were the reason that these internal packages were just internal packages, and not all there was to these SDKs.

Maybe you can already see some issues with this scheme. The biggest one for us was that whenever we added something new to our public HTTP API, we had to write new code for the public API of each of the SDKs, which came with extra opportunities for errors and inconsistencies to sneak in.

Another issue (unrelated to the split between internal generated code and hand-written public API) came from the specific tool we used: OpenAPI generator. We were very happy with it starting out, but more recently when introducing oneOf schemas into our OpenAPI spec, found that it handles them pretty awkwardly. I don't see this as a fault of the tool so much since there are many weird patterns one can implement using oneOf schemas, and finding a reasonable representation for some or all of them in lots of target languages is not an easy task. However, for just the one oneOf pattern we have in our spec so far, it was not that hard to figure out how to do better.

Finally, upgrading OpenAPI generator was a rather problematic process, as we were using the project's bundled templates for the most part, so those would get upgraded alongside the tool itself. Template changes routinely contained breaking changes to the generated code - sometimes ones that were required to fix bugs but other times it was things like including dots from OpenAPI operation IDs in names all of a sudden (as Dot in the resulting symbol names, and only in a single template).

GitHub Pull Request header with almost 20k lines added and 12.5k lines removed

This PR to upgrade OpenAPI generator was quite something.

Why create a whole new code generator for this?

As already revealed by the title of this article, we solved these issues by creating our own tool for generating the API clients. But why?

Nowaways, there are a large number of solutions for quickly propping up an SDK given an OpenAPI specification. In addition to the open source OpenAPI generator we were using before, there are now services like Stainless, Speakeasy and fern.

However, we were not looking to set up new SDKs - as mentioned in the previous section we had a public API for each language we were largely happy with, the issues were all around the internal tech debt and the amount of manual work we were doing whenever we extended our API specification. Switching to one of the aforementioned services would have also meant dropping some of our existing SDKs in addition to breaking the API of SDKs we could replace that way. This was a no-go.

So we started an experiment: As a first step, could we get rid of all the manual work for adding new endpoints to the SDKs by generating our high-level APIs, while still letting OpenAPI generator take care of the implementation?

How it went

One thing I knew before writing a single line of code was the templating engine I wanted to use: MiniJinja, a Rust implementation of Jinja2 by its original author. OpenAPI generator's templates are written in mustache, which markets itself with the line "logic-less templates". I am surprised they chose this language, as many of their templates (some of which we had copied to tweak them) were losing readability to excessive template tag nesting, largely stemming from the absence of boolean operators in mustache.

A single templated Rust attribute (serde) of 341 characters, including six pairs of mustache tags, wrapped across six lines for readability

A single line of the OpenAPI generator Rust model template.

I had used MiniJinja before for personal projects, and it helped me quickly achieve my goals here once again. From adding custom filters to tweaking the template loading logic, I got everything I wanted out of it with very little ceremony.

But back to the beginning: It took less than a week from nothing to generating a directory with files that were already quite close to the high-level API of our Rust SDK. The first useful product of this was documentation for most of the Rust SDK, added by replacing the hand-written Rust sources with the generated code, then reverting any as-of-yet unresolved differences between the two.

After the first successful first steps, we expanded the scope into other languages, and later to replacing OpenAPI generator entirely - however only for those things we did see a need for automation for. In particular, OpenAPI generator generates an entire package but this was a definite non-goal for us. We're not trying to position openapi-codegen as a competition to the existing tools that do this, which already do a great job if you don't want tight control over the exact implementation and API of your SDK(s).

Ultimately, we were able to replace all usage of OpenAPI generator. Some artifacts of the packages it originally generated surely remain, but there is no longer a reason to to regenerate anything that doesn't have templates for our own generator. After this, it was no longer a big daunting task to extend beyond the codegen capabilities we had before. This is the bulk of the template code for oneOf JSON Schemas for the Rust SDK, for instance:

use serde::{Deserialize, Serialize};

use super::{
    {% for c in referenced_components -%}
        {{ c | to_snake_case }}::{{ c | to_upper_camel_case }},
    {% endfor -%}
};

{% set type_name = type.name | to_upper_camel_case -%}
{% if type.fields | length > 0 -%}
    {% set enum_type_name %}{{ type_name }}{{ type.content_field | to_upper_camel_case }}{% endset -%}

    {{ doc_comment }}
    #[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)]
    pub struct {{ type_name }} {
        {% include "types/struct_fields.rs.jinja" %}

        #[serde(flatten)]
        {% set enum_field_name = type.content_field | to_snake_case %}
        {% if type.content_field != enum_field_name -%}
            #[serde(rename = "{{ type.content_field }}")]
        {% endif -%}
        pub {{ enum_field_name }}: {{ enum_type_name }},
    }
{% else -%}
    {% set enum_type_name = type_name %}
{% endif %}

#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[serde(tag = "{{ type.discriminator_field }}", content = "{{ type.content_field }}")]
pub enum {{ enum_type_name }} {
    {% for variant in type.variants -%}
        #[serde(rename = "{{ variant.name }}")]
        {{ variant.name | to_upper_camel_case -}}
        {% if variant.schema_ref is defined -%}
            ({{ variant.schema_ref | to_upper_camel_case }})
        {%- endif %},
    {% endfor -%}
}

As a very nice side effect, the new JS SDK, which has much more straight-forward HTTP request code, is much smaller in resulting JS bundles: For our application portal, the share of the SDK went from 750KB to 150KB, an 80% reduction (30% of the entire bundle)!

Extending beyond traditional API clients

Around the same time we thought of creating our own code generation tool, we were also considering a rewrite of our CLI from Go to Rust. Here, we had the same issue as with the high-level APIs of our SDKs: It was all written by hand. The rewrite was the perfect opportunity to try out generating the code for translating command-line arguments to API calls (via our Rust SDK).

This effort quickly found success and we achieved parity with the old Go codebase before we had fully replaced OpenAPI generator in the SDKs. Now the only hand-written parts of the CLI are the ones that aren't just API calls, for example svix login and svix signature.

Closing words

When we started this effort, it didn't seem likely that we would be able to fully replace our existing code generation workflow. OpenAPI generator has more than 20k commits on GitHub, which is the same order of magnitude as our major internal repositories combined. We're all the more happy that we tried because our SDK update workflow has gotten simpler, more robust and we've been able to ship new APIs without any ugly server-side hacks to make the spec match what OpenAPI generator can generate decent code for.

If you're in a situation as we were when we started this, you're welcome to try out openapi-generator - it's free and open source under the MIT license. We intend to continue maintaining it publicly for our own needs, and are happy to accept small contributions. If you want to contribute larger changes, please open a discussion for discussion before working on a PR.


For more content like this, make sure to follow us on Twitter, Github, RSS, or our newsletter for the latest updates for the Svix webhook service, or join the discussion on our community Slack.

Footnotes

  1. maybe the generator's authors simply had priorities that went against cross-language consistency, or the templates for different languages were written / maintained by different people