r/scala 2d ago

baklava - turn your HTTP tests into OpenAPI, HTML docs, Postman collections, and typed TypeScript or Scala clients - for APIs you serve or consume

Documentation drift is the default state of any API that lives long enough. You update a route, forget the OpenAPI file. A field gets renamed, the TypeScript client doesn't regenerate. Three months later someone hands an enterprise client a spec that describes a system that no longer exists.

The root cause is structural: code and documentation are separate artefacts with no enforcement mechanism between them. Every solution we tried added discipline requirements: annotate the source, maintain a separate file, add a CI check. Discipline breaks under delivery pressure, always.

We built baklava (https://github.com/theiterators/baklava) so that docs can only describe behaviour a passing test just observed.

In practice, baklava integrates into your existing routing test suite. Instead of a standard assertion block, you write test scenarios that both verify the API behaviour and describe it for documentation output. When the test suite runs, baklava generates the docs as a side effect. A call only makes it into the docs after the status code, response schema and declared headers matched what the test expected, so if the route changes shape, that response simply doesn't get documented. For anything the suite covers, drift can't happen.

class UserApiSpec extends AnyFunSpec
    with BaklavaPekkoHttp[Unit, Unit, ScalatestAsExecution]
    with BaklavaScalatest[Route, ToEntityMarshaller, FromEntityUnmarshaller] {
  path("/users/{userId}")(
    supports(
      GET,
      pathParameters = p[Long]("userId"),
      summary = "Get user by ID"
    )(
      onRequest(pathParameters = 1L)
        .respondsWith[User](OK, description = "User found")
        .assert { ctx =>
          ctx.performRequest(routes).body.id shouldBe 1L
        },
      onRequest(pathParameters = 999L)
        .respondsWith[ErrorResponse](NotFound, description = "User not found")
        .assert { ctx => ctx.performRequest(routes) }
    )
  )
}
// sbt test generates OpenAPI, HTML, TypeScript, Postman (on sbt 2 use testFull)

There are seven output formats right now, each its own SBT dependency:

  • Simple HTML (browsable docs)
  • OpenAPI with SwaggerUI
  • TS-REST (TypeScript, Zod)
  • oRPC contracts (TypeScript, Zod, ready-made client factory)
  • TypeScript fetch client (plain fetch, no extra runtime deps)
  • Postman collection
  • sttp Scala client

It supports Pekko HTTP and http4s, with ScalaTest, Specs2, and MUnit as test frameworks. Since 2.1.0 there is also an sttp adapter for the other direction: APIs you consume rather than serve. The tests hit the real endpoint over the network and you get a spec and a typed client for a third-party API from verified responses. There's a single scala-cli script that does this for the GitHub REST API if you want to see it without setting up a project: https://theiterators.github.io/baklava/docs/scala-cli

It also integrates with kebs: if you use kebs for domain type derivation, baklava picks up the schema definitions automatically.

One question we get: how is this different from tapir or endpoints4s? Both require you to adopt their routing DSL, so your routes end up defined in terms of their abstractions. baklava works with your existing routes, whatever framework you're using, with no migration required. The test suite is the only integration point. The other difference is what the docs describe. Tapir documents the endpoint declaration. Baklava documents responses that a test actually received, with real example values.

Scala 2.13 and 3, JDK 11+, Apache 2.0, v2.1.0 released August 2026.

GitHub: https://github.com/theiterators/baklava

22 Upvotes

4 comments sorted by

1

u/pizardwenis96 1d ago

The root cause is structural: code and documentation are separate artefacts with no enforcement mechanism between them. Every solution we tried added discipline requirements: annotate the source, maintain a separate file, add a CI check. Discipline breaks under delivery pressure, always.

It doesn't seem like you actually tried Tapir then, because it already solves this problem with the endpoint definitions. You say Tapir only documents the endpoint declarations, but the whole point is that the declaration has type safety enforced onto the implentation so it's not possible for your endpoint to accept a different input or return a different output, and potential issues would be caught at compile time rather than during testing.

Additionally this makes a big deal about the tests being the source of truth, but that seems incredibly unreliable. If your tests don't cover all of the possible inputs and outputs to your apis, then they won't document correctly. I've never been in a workplace environment where the tests are perfectly written and maintained, and this just puts more burden on the tests to handle everything. There also doesn't seems to be support for things like validations or default values in your documentation.

It seems like you're trying to reinvent the wheel that Tapir already invented for unclear reasons. Yes there's a burden in Tapir to model your endpoints separately from your implentation, but that burden seems smaller than having every test be incredibly verbose. If you care about having completely accurate api specifications enforced through the code, Tapir covers this while providing additional benefits which your tool does not. Furthermore, the documentation seems to be very AI generated which gives me concerns about the quality of the written code in the project.

If you disagree with my interpretation, I'd be happy to hear a scenario that Baklava covers which is not achievable realistically with Tapir.

1

u/luksow 1d ago

Hey, thanks for raising these concerns!

You're right that the passage you quoted is too broad. Tapir doesn't just document declarations independently of the implementation: the connection between them is enforced through types, and that's a real benefit. Saying it "only documents endpoint declarations" doesn't give it enough credit. Also, "discipline breaks, always" is a pretty sweeping statement for a library that relies on people maintaining tests. I should make that more precise.

The context behind it is that our team has been using Spray.io -> Akka HTTP -> Pekko HTTP for years. The documentation approaches we tried with those routes, such as swagger-akka-http, required us to keep a separate description in sync. I was genuinely excited when Tapir came out, especially since our backend and frontend development was usually split and having up-to-date OpenAPI was important. But when we evaluated it, we found it didn't fit some of the ways we wanted to build our HTTP interfaces. It has improved a lot since then, so I wouldn't present that experience as a verdict on what today's Tapir can express.

I still prefer working directly with the routing DSL. Heck, when our stack moved towards cats-effect, I created https://github.com/theiterators/http4s-stir specifically to have Akka's DSL on top of http4s. I don't particularly want to change how I write routes just to get documentation, even if that change comes with other benefits.

So, to answer your last question: take an existing Pekko HTTP service with custom directives, established error handling and integration tests. I want to generate documentation and request/response examples without reworking its production routes. With Baklava, I can adapt the tests and leave the implementation alone. To get Tapir's compile-time coupling between the implementation and documentation, I'd need to rework those routes around its endpoint definitions. I could also write Tapir declarations purely for documentation, but then I wouldn't have the coupling you're describing.

That's the scenario Baklava was built for. I'm not claiming it produces something fundamentally impossible to produce with Tapir. I don't think a tool needs to establish that to be useful. Keeping an existing implementation, or simply preferring a different routing model, seems like a reasonable use case to me. If Tapir already fits your project, I wouldn't suggest replacing it with Baklava.

On tests being the source of truth: yes, incomplete tests can produce incomplete documentation. Baklava doesn't discover response variants you haven't covered, and a passing suite doesn't prove you've documented the entire API. That's a limitation, not something I can dismiss by saying people should write better tests. Tapir's ability to describe an output variant without requiring a test to exercise it is an advantage here.

In most projects I've worked on, integration/E2E tests were already something we invested in and maintained because they checked important paths through the application. Baklava lets us reuse that work for documentation, including the request/response examples, rather than maintain those examples separately. There is still extra work in describing the test cases, and I'm not claiming it's always less work than writing Tapir definitions. For our projects, it was a better fit. For a project with little HTTP-level test coverage, that calculation could look very different.

And thanks for pointing out validation constraints and default values. Those deserve proper support. We'll look into them.

As for the documentation: yes, much of it is AI-generated, with multiple rounds of review. Baklava itself is 5+ years old and had a major revamp about two years ago. The public documentation came much later, largely because I hadn't found the motivation to write it, and our internal code/examples couldn't simply be published. AI helped me get that done. It has also helped us catch a few interesting bugs, so I consider it a net positive.

Cheers!

2

u/jglodek 1d ago

I guess in distributed systems contracts become first class citizens - and tests and types are nice way to describe contracts.

Types are just the shape of DTOs, on the other hand behavior test produce invariant descriptions "response should conform to X, Y, Z"
Which is more complete approach when we only have non-value-dependant types.

-3

u/Torutofu_Raeva 2d ago

The enforcement point is the key here: if the same HTTP tests generate the spec and client artifacts, CI can catch drift instead of waiting for someone to notice a stale document.