Skip to main content

Output Formats

Baklava supports seven output formats. You can use one or more simultaneously — each is an independent SBT dependency that produces its own output in target/baklava/.

How It Works

Formatters are automatically discovered via reflection. Any formatter on the test classpath is picked up and run — no registration or configuration needed (beyond format-specific config like openapi-info). Just add the dependency and it works:

libraryDependencies ++= Seq(
"pl.iterators" %% "baklava-simple" % "VERSION" % Test, // adds Simple format
"pl.iterators" %% "baklava-openapi" % "VERSION" % Test, // adds OpenAPI format
"pl.iterators" %% "baklava-tsrest" % "VERSION" % Test, // adds TS-REST format
"pl.iterators" %% "baklava-orpc" % "VERSION" % Test, // adds oRPC contract format
"pl.iterators" %% "baklava-tsfetch" % "VERSION" % Test, // adds TypeScript fetch client
"pl.iterators" %% "baklava-postman" % "VERSION" % Test, // adds Postman Collection format
"pl.iterators" %% "baklava-sttpclient" % "VERSION" % Test // adds Scala sttp-client stubs
)

The generation pipeline:

  1. During sbt test, each test case is serialized to a JSON file in target/baklava/calls/
  2. After tests complete, the SBT plugin runs BaklavaGenerate which reads all call files
  3. Every formatter found on the classpath processes the calls and writes its output
  4. The call files are cleaned up

Simple Format

Dependency: "pl.iterators" %% "baklava-simple" % "VERSION" % Test Configuration: None required Output: target/baklava/simple/

Generates self-contained HTML files you can open in any browser:

  • index.html — navigation page listing all endpoints (method + route), linking to individual pages
  • One HTML file per endpoint, named by method and path (e.g., GET__user__username__.html)

Each endpoint page contains an HTML table with:

  • Method, route, summary, description
  • Authentication schemes (if any)
  • Headers, path parameters, query parameters (with types and required indicators)
  • Status codes from test cases
  • Request body examples (JSON pretty-printed)
  • Request body schema (JSON Schema Draft 7)
  • Response body examples per status code
  • Response body schema per status code

This is the simplest format to get started — no configuration, no external tools needed.

OpenAPI Format

Dependency: "pl.iterators" %% "baklava-openapi" % "VERSION" % Test Configuration: Required — openapi-info key in baklavaGenerateConfigs Output: target/baklava/openapi/openapi.yml

Generates a single OpenAPI 3.0.1 YAML specification file containing:

  • Paths organized by route and HTTP method, each with:
    • operationId, summary, description, tags
    • Parameters (query, path, header) with schemas, types, required flags, enum values
    • Request body with media type, schema, and multiple examples from different test cases
    • Responses grouped by status code, each with schema, examples, and response headers
  • Security schemes auto-detected from your test cases (bearer, basic, API key, OAuth2, OpenID Connect, mutual TLS)
  • Components section with all referenced security scheme definitions

When multiple test cases cover the same endpoint with different inputs/outputs, they appear as separate examples in the OpenAPI spec.

Configuration

baklavaGenerateConfigs := Map(
"openapi-info" ->
s"""
|openapi: 3.0.1
|info:
| title: My API
| version: 1.0.0
|""".stripMargin
)

The openapi-info value can be JSON or YAML and supports all OpenAPI info fields (title, version, description, contact, license, termsOfService).

SwaggerUI (Pekko HTTP only)

Add "pl.iterators" %% "baklava-pekko-http-routes" % "VERSION" (not test-scoped) to serve the generated spec via SwaggerUI at runtime. See Installation — SwaggerUI for setup.

Post-Processing

You can programmatically modify the generated OpenAPI spec by implementing BaklavaOpenApiPostProcessor. Implementations are discovered automatically via reflection — no registration needed. See Configuration — Post-Processing for details.

TypeScript REST (TS-REST) Format

Dependency: "pl.iterators" %% "baklava-tsrest" % "VERSION" % Test Configuration: Required — ts-rest-package-contract-json key in baklavaGenerateConfigs Output: target/baklava/tsrest/

Generates a complete TypeScript npm package using ts-rest and Zod for type-safe API contracts. The output can be published to npm or used as a local dependency in your frontend project.

Generated Files

  • package.json — npm package with build scripts, peer dependencies on @ts-rest/core and zod
  • tsconfig.json — TypeScript configuration (ES2022, strict mode)
  • src/contracts.ts — main exports file assembling the module routers into one nested ts-rest router
  • src/{area}.contract.ts — one module file per top-level path area (/users/...users.contract.ts). A namespace — a version prefix (/v1/...) or any segment fronting two or more named sub-resources (/admin/config, /admin/loggers, …) — becomes a folder with a file per sub-resource (src/v1/auctions.contract.ts, src/admin/loggers.contract.ts); a single-resource area (/users + /users/{id}) stays one flat file
  • src/{area}.schemas.ts / src/schemas.ts — named zod schemas (one per captured case class, deduplicated), each paired with its inferred type export (auctionDtoSchema + export type AuctionDto): module-local ones sit next to their contract, shapes shared by two or more modules land in the common schemas.ts

Contract Organization

Contracts nest by path segment, so /users/{userId}/photo is reached as contracts.users.byUserId.photo.<method>. A path parameter reads as by<Param> ({userId}byUserId) — the router-tree spelling of the getUsersByUserId convention the TS-Fetch format uses for function names; static segments are camelized (feature-flagsfeatureFlags). Endpoints sharing a path with different HTTP methods are sibling entries on the same node. Each module file exports an initContract().router(...) subtree; contracts.ts mounts them.

Zod Schema Mapping

Baklava schemas are converted to Zod validators:

Baklava SchemaZod Output
Stringz.string()
String (email format)z.string().email()
String (uuid format)z.string().uuid()
String (date-time format)z.coerce.date()
String (enum)z.enum(["val1", "val2"])
Int, Longz.number().int()
Double, Floatz.number()
Booleanz.boolean()
Seq[T], List[T]z.array(innerSchema)
Case classz.object({ field: schema, ... })
Map[K, V]z.record(z.string(), innerSchema)
Option[T]schema.nullish()

When multiple test cases produce different schemas for the same endpoint input/output, they are combined into z.union([...]).

Object keys that aren't valid JavaScript identifiers (e.g. kebab-case query/header parameters like seller-id or X-Forwarded-For) are emitted quoted so the generated source compiles.

multipart/form-data request bodies (a Multipart(...) body in the test) are emitted as contentType: 'multipart/form-data' plus a z.object({...}) naming each captured form part — FileParts become z.instanceof(File), TextParts become z.string(), and a part name that appears more than once (a multi-value field) becomes a z.array(...). Distinct part-sets recorded across calls for the same endpoint are combined into a z.union([...]), like any other body shape. (z.instanceof(File) references the global File constructor; it's available in browsers and Node ≥ 20.)

Security

ts-rest has no route-level OpenAPI security field — in ts-rest, security is applied by an operationMapper that the consumer passes to generateOpenApi. So a route's captured schemes are surfaced in the description (e.g. Requires authentication: basicAuth (HTTP Basic).) rather than structurally. To turn that into real OpenAPI security, define the schemes in generateOpenApi's components.securitySchemes and map them per operation. Authentication itself is a transport concern handled by the client, not the contract.

Configuration

baklavaGenerateConfigs := Map(
"ts-rest-package-contract-json" ->
"""
|{
| "name": "@company/backend-contracts",
| "version": "1.0.0",
| "main": "index.js",
| "types": "index.d.ts"
|}
|""".stripMargin
)

Usage in Frontend

After generating and building the package:

cd target/baklava/tsrest
pnpm install
pnpm run build

Then import in your TypeScript project:

import { contracts } from "@company/backend-contracts";

// Full type safety and autocompletion for API calls
const userContract = contracts.user;

oRPC Contract Format

Dependency: "pl.iterators" %% "baklava-orpc" % "VERSION" % Test Configuration: Optional — orpc-package-contract-json key in baklavaGenerateConfigs Output: target/baklava/orpc/

Generates a TypeScript npm package of oRPC contracts (@orpc/contract + Zod). Consume it from any TypeScript frontend through OpenAPILink, which speaks plain REST against the documented backend — plus oRPC's first-class TanStack Query utilities.

Generated Files

  • package.json — npm package with build scripts, peer dependencies on @orpc/contract, @orpc/client, @orpc/openapi-client and zod (v4)
  • tsconfig.json — TypeScript configuration (ES2022, strict mode)
  • src/contracts.ts — main exports file assembling the module files into one nested router object
  • src/{area}.contract.ts — one module file per top-level path area (/users/...users.contract.ts). A namespace — a version prefix (/v1/...) or any segment fronting two or more named sub-resources (/admin/config, /admin/loggers, …) — becomes a folder with a file per sub-resource (src/v1/auctions.contract.ts, src/admin/loggers.contract.ts); a single-resource area (/users + /users/{id}) stays one flat file
  • src/{area}.schemas.ts — named, deduplicated schemas used only by that module: every object schema with a captured case-class name is hoisted under a derived name and paired with its inferred type (AuctionDtoauctionDtoSchema + export type AuctionDto); a type name that would shadow a TS global gets a Type suffix (ErrorErrorType)
  • src/schemas.ts — hoisted schemas shared by two or more modules (errorSchema, common DTOs)
  • src/client.ts — a ready-made client factory (createContractsClient(url)): an OpenAPILink whose error decoder lifts the backend's discriminated error bodies into defined ORPCErrors under the declared codes
  • src/security.tssecuritySchemes: the OpenAPI Security Scheme Objects captured from the routes ({ basicAuth: { type: 'http', scheme: 'basic' }, … }), keyed by scheme name — feed it to an OpenAPI generator's components.securitySchemes

Contracts nest by path segment — oRPC's native router shape — so /v1/auctions/{auctionId}/bids is reached as contracts.v1.auctions.byAuctionId.bids.<method>. A path parameter reads as by<Param> ({auctionId}byAuctionId), the router-tree spelling of the getUsersByUserId convention the TS-Fetch format uses for function names; static segments are camelized (feature-flagsfeatureFlags). Schemas use the zod 4 vocabulary and deliberately tell the wire truth: date-time fields render z.iso.datetime({ offset: true }) (over HTTP a timestamp is an ISO string and OpenAPILink performs no client-side coercion), uuidz.uuid(), emailz.email(), multipart file parts → z.file(). The rest of the Zod mapping table above applies unchanged.

Contract Shape

Each module file exports a nested router: procedures keyed by lowercase HTTP method, child path segments as sub-objects, built with oc.route(...):

export const users = {
get: oc
.route({ method: 'GET', path: '/users', /* ... */ }),
byUserId: {
get: oc
.route({
method: 'GET',
path: '/users/{userId}',
summary: 'Get user',
description: 'Fetch a single user by UUID',
operationId: 'getUser',
tags: ['Users'],
successStatus: 200,
inputStructure: 'detailed'
})
.input(z.object({
params: z.object({userId: z.uuid()})
}))
.output(z.object({ /* ... */ }))
.errors({
'not_found': {
status: 404,
data: z.object({ /* the captured error body shape */ })
}
}),
},
};

Mapping decisions:

  • Paths keep {param} placeholders — oRPC's native syntax, no conversion.
  • inputStructure: 'detailed' throughout: the input object has explicit params (path), query, headers, and body groups. A query/header group whose parameters are all optional is emitted .optional(), so callers may omit it.
  • Success statuses are preserved truthfully: with one captured 2xx status it becomes successStatus and the body is the compact .output(...) (bodyless success renders z.void()). With several distinct 2xx statuses the procedure switches to outputStructure: 'detailed' and the output is a union of { status: z.literal(s), body } objects — which-status is real information the API returns, and a flat body union would destroy it.
  • Multipart bodies render as z.object fields with z.instanceof(File) / z.string(); oRPC serializes File-bearing bodies as multipart/form-data on the wire.
  • tags and operationId are emitted when captured, so OpenAPI specs generated from the contract group and name operations like the original.

Error Responses

Backends documented by baklava don't speak oRPC's own error envelope, but most speak a discriminated one — RFC 9457 Problem Details' type, or a custom code field. The generator exploits that: for every non-2xx response it extracts the discriminator value from the captured example body (schemas only know the field is a string; the example carries the literal) and declares a typed error keyed by it:

.errors({
'result:bid-too-low': {
status: 409,
data: z.object({ /* the captured error body shape */ })
}
})

The discriminator field defaults to type and is configurable via the orpc-error-code-field config key. Inside each declared error's data schema the discriminator property is narrowed to z.enum(["<the code>"]), so payloads are self-discriminating; when the error body shape is hoisted, the narrowing happens at the use site (errorSchema.extend({type: z.enum(["<the code>"])})) so one shared schema serves every declared code. Error responses whose body carries no extractable discriminator (bodyless 429s, non-JSON payloads) are left undeclared and surface through oRPC's defaults.

The generated src/client.ts completes the loop: its createContractsClient(url) wires an OpenAPILink with a customErrorResponseBodyDecoder that lifts error bodies into ORPCErrors whose code is the same discriminator value (and defined: true) — so isDefinedError narrows errors to the declared union, giving statically typed, exhaustively checkable error handling end to end, out of the box.

Security

Routes that captured a security scheme add it to the generated OpenAPI operation via oRPC's route spec override:

.route({
method: 'GET',
path: '/admin/loggers/{name}',
/* ... */
spec: (current) => ({ ...current, security: [{ basicAuth: [] }] }),
})

security references schemes by name; the matching definitions are exported from src/security.ts as securitySchemes. Pass them to your OpenAPI generator so the references resolve. Authentication itself stays a transport concern — inject credentials via the client's fetch/headers (as createContractsClient's options allow), the same way bearer tokens are added; the contract only documents the requirement.

Configuration

baklavaGenerateConfigs := Map(
"orpc-package-contract-json" ->
"""
|{
| "name": "@company/backend-orpc-contracts",
| "version": "1.0.0",
| "main": "index.js",
| "types": "index.d.ts"
|}
|""".stripMargin,
// optional: which top-level field of an error body discriminates error kinds (default: "type")
"orpc-error-code-field" -> "type"
)

Usage in Frontend

import { createContractsClient } from "@company/backend-orpc-contracts/client";
import { isDefinedError } from "@orpc/client";

const client = createContractsClient("https://api.example.com");

// Full type safety; types are wire-true (dates are ISO strings)
const user = await client.users.byUserId.get({ params: { userId } });

try {
await client.users.byUserId.get({ params: { userId: missing } });
} catch (error) {
if (isDefinedError(error)) {
// narrowed to the declared union; error.data is the typed error body
}
}

For TanStack Query, wrap the client with createTanstackQueryUtils from @orpc/tanstack-query. To customize the link (auth-aware fetch, headers), pass { fetch, headers } to createContractsClient, or build your own OpenAPILink — the generated client.ts shows the error-decoder recipe.

Postman Collection Format

Dependency: "pl.iterators" %% "baklava-postman" % "VERSION" % Test Configuration: Optional — postman.collectionName key in baklavaGenerateConfigs Output: target/baklava/postman/collection.json

Generates a Postman Collection v2.1 JSON document. The file imports cleanly into Postman (desktop, web, and CLI) and Insomnia (via its Postman v2 import path).

What Gets Generated

  • Folders grouped by the operation's first tag. Untagged operations appear at the collection root.
  • Requests with method, URL, headers, body, and authentication block per endpoint.
  • OpenAPI-style path placeholders (/users/{userId}) rewritten as Postman's :userId syntax, with captured example values promoted to per-request variable[] entries.
  • Query and header parameters from the DSL with captured example values.
  • Request bodies rendered as mode: raw with language (json, xml, javascript, html, or text) inferred from the captured Content-Type.
  • Response examples — each test case becomes a saved response example under its endpoint, labelled with the responseDescription or <status> response.
  • Security schemes translated to Postman's native auth block:
    • HttpBearer → Bearer Token
    • HttpBasic → Basic Auth
    • ApiKeyInHeader / ApiKeyInQuery / ApiKeyInCookie → API Key (with matching in location)
    • OAuth2InBearer / OpenIdConnectInBearer → OAuth 2.0 (token in header)
    • OAuth2InCookie / OpenIdConnectInCookie → API Key with in: cookie (Baklava doesn't capture the cookie name at scheme-definition time, so the user fills it in after import)
    • MutualTls → no auth block (no Postman equivalent; client-cert setup is external to the collection)
  • Collection-level variables with empty placeholder values — {{baseUrl}} plus one per security scheme's credentials:
    • Bearer → {scheme}Token
    • Basic → {scheme}Username + {scheme}Password
    • API key (any in) → {scheme}Value
    • OAuth / OpenID Connect in bearer → {scheme}Token
    • OAuth / OpenID Connect in cookie → {scheme}CookieName + {scheme}Token

Configuration

baklavaGenerateConfigs := Map(
"postman.collectionName" -> "My API"
)

Defaults to "Baklava-generated API" when unset.

Usage

After generating:

  1. Postman — File menu → Import → pick target/baklava/postman/collection.json.
  2. Insomnia — Application menu → Import → choose the file, select "Postman v2" when prompted.

After importing, set the baseUrl collection variable (e.g., https://api.example.com) plus any security-credential variables. Each request then sends against your live server with correct paths, headers, bodies, and auth.

Caveats

  • Postman permits only one auth block per request, so when an endpoint declares multiple SecuritySchemes, only the first maps to the native auth block. Users can switch alternatives manually in the Postman UI after import.
  • Body serialization uses the raw captured string from the test. If your DSL passes a Scala case class whose JSON encoding has nested escaped strings, those will appear as-is in the request body (as they would on the wire).
  • The generator does not emit Postman test scripts or pre-request scripts — it only reproduces the request/response shape. Response examples are attached for visual inspection, not for assertions.

Scala sttp-client Format

Dependency: "pl.iterators" %% "baklava-sttpclient" % "VERSION" % Test Configuration: Optional — sttp-client-package key in baklavaGenerateConfigs Output: target/baklava/sttpclient/

Generates a tree of Scala source files containing sttp-client4 request builders for every documented endpoint. Named JSON request and response bodies use sttp-client4's circe integration where applicable. The typed Request[Either[ResponseException[String], T]] shape is only emitted when the endpoint has a decodable typed 2xx JSON response; endpoints without a typed response — including ones that only have a typed request body — fall back to Request[Either[String, String]]. Either way, you send with any sttp backend (sync, async, Future, fs2, ZIO, etc.).

Generated Files

  • README.md — usage overview
  • src/main/scala/{package}/common/dtos.scala — case classes shared by two or more tags (omitted if empty)
  • src/main/scala/{package}/{tag}/dtos.scala — case classes used only within that tag (omitted if empty)
  • src/main/scala/{package}/{tag}/{Tag}Endpoints.scala — one {Tag}Endpoints object with a def per endpoint. Untagged operations land in default/DefaultEndpoints.scala.

Package name defaults to baklavaclient and can be overridden via the sttp-client-package config key. Each {Tag}Endpoints.scala file emits import statements for the common sub-package and any cross-tag types it references, so method bodies can use short class names.

Type Distribution

Each named schema is routed based on how many tags' endpoints reference it:

  • Used by one tag{tag}/dtos.scala in that tag's sub-package
  • Used by two or more tagscommon/dtos.scala under the common sub-package

{Tag}Endpoints.scala files emit Scala import statements pointing at the right sub-package, so endpoint method bodies can use the short class name directly.

Typed bodies and responses

Request-body typing and response typing are decided independently.

Typed request body — when a request body resolves to a named case class (or Seq[NamedClass]) and all captures on the endpoint agree on a JSON-ish Content-Type:

  • Signature becomes body: SomeRequest
  • Serialization: .body(body.asJson.noSpaces).contentType("<captured Content-Type>") (reuses the captured value, e.g. application/vnd.api+json; charset=utf-8; falls back to application/json when none was captured)
  • File imports sttp.client4.circe._ + io.circe.generic.auto._ + io.circe.syntax._

Typed response — when every 2xx capture has the same named case class (or Seq[NamedClass]) and declares a JSON-ish responseContentType:

  • Return becomes Request[Either[ResponseException[String], SomeResponse]]
  • Decoding: .response(asJson[SomeResponse])
  • File imports sttp.client4.circe._ + io.circe.generic.auto._

Raw fallback — when the body isn't a named case class (multipart/form, plain text, empty), the endpoint keeps the raw bodyJson: String input. When the response isn't a named JSON schema (or 2xx responses are mixed JSON and non-JSON), the endpoint keeps the raw Either[String, String] response. An endpoint can mix a typed body with a raw response (or vice versa) — each side is gated on its own.

Consumers need sttp-client4-circe and circe-generic on the classpath when any endpoint hits the typed paths above.

Endpoint Shape

Each generated def is curried into two parameter lists. The first carries connection-level state that's typically set once per session; the second carries per-call inputs:

First parameter list (connection-level):

  1. baseUri: sttp.model.Uri
  2. Credential parameters per the first SecurityScheme ({schemeName}Token / {schemeName}Username+{schemeName}Password / {schemeName}Value / {schemeName}CookieName+{schemeName}Token). Scheme names that collide with Scala reserved words (e.g. type) are sanitized so the final identifier always compiles.

Second parameter list (per-call):

  1. Path parameters as required positional parameters
  2. Query parameters (required-typed or Option[T] = None)
  3. Declared headers (same required/optional handling)
  4. Either body: SomeRequest (typed path) or bodyJson: String (raw path, including multipart/form)

If an endpoint has no per-call inputs, the generator collapses the signature to a single param list so callers don't have to write trailing ().

The currying makes the connection vs. per-call separation visible at the call site and lets you partially apply the connection — val getOnApi = getUser(base, token) _ gives you a function over per-call inputs you can pass around.

Example (typed, generated for POST /users returning User):

def createUser(
baseUri: Uri,
bearerAuthToken: String
)(
body: CreateUserRequest
): Request[Either[ResponseException[String], User]] = {
basicRequest
.post(baseUri.addPath("users"))
.header("Authorization", s"Bearer ${bearerAuthToken}")
.body(body.asJson.noSpaces)
.contentType("application/json")
.response(asJson[User])
}

Example (raw fallback, generated for POST /users/{userId}/photo with multipart/form-data):

def uploadPhoto(
baseUri: Uri,
bearerAuthToken: String
)(
userId: java.util.UUID,
bodyJson: String
): Request[Either[String, String]] = {
basicRequest
.post(baseUri.addPath("users", s"$userId", "photo"))
.header("Authorization", s"Bearer ${bearerAuthToken}")
.body(bodyJson)
.contentType("multipart/form-data; boundary=...")
}

HTTP Methods

Well-known verbs (GET/POST/PUT/DELETE/PATCH/HEAD/OPTIONS) use the convenience builders on basicRequest (.get(uri), .post(uri), …). Uncommon or extension verbs (PROPFIND, PURGE, …) fall back to .method(sttp.model.Method("X"), uri), so the generated code compiles regardless of what the DSL captured.

Security Mappings

SchemeCredential parameter(s)Wiring
HttpBearer{scheme}Token: String.header("Authorization", s"Bearer ${...Token}")
HttpBasic{scheme}Username, {scheme}Password: String.auth.basic(username, password)
ApiKeyInHeader{scheme}Value: String.header("<key>", value)
ApiKeyInCookie{scheme}Value: String.cookie("<key>", value)
ApiKeyInQuery{scheme}Value: String.addParam("<key>", value) on the URI chain
OAuth2InBearer/OpenIdConnectInBearer{scheme}Token: String.header("Authorization", s"Bearer ${...Token}")
OAuth2InCookie/OpenIdConnectInCookie{scheme}CookieName, {scheme}Token: String.cookie({scheme}CookieName, {scheme}Token) (cookie name isn't part of the scheme)
MutualTlsNot wired — client-cert setup is external to the generated code

Only the first SecurityScheme maps to generated parameters. Endpoints using multiple schemes need additional headers supplied manually.

Content-Type

Endpoints with a request body emit .contentType(...) honoring the content-type captured by Baklava. When every call on an endpoint declared the same content-type (e.g. multipart/form-data), the generated code uses that value; otherwise it defaults to application/json. The bodyJson parameter name is a historical convention — the generator itself doesn't assume JSON, so you can pass any pre-serialized payload.

Schema → Scala Type Mapping

Baklava SchemaScala
StringString
String (uuid)java.util.UUID
String (enum)String (user refines manually if desired)
IntInt
Long (int64 format)Long
Float, Double, BigDecimalFloat, Double, BigDecimal
BooleanBoolean
Seq/List/Vector/Set/Array[T]Seq[T]
Named case classCase class (emitted in the owning tag's dtos.scala or common/dtos.scala)
Map[K, V]Map[String, V]
Option[T]Field becomes Option[T] = None

Configuration

baklavaGenerateConfigs := Map(
"sttp-client-package" -> "com.example.api.client"
)

Usage in a Scala Project

Copy the generated tree into your project under a matching package, add sttp-client4 + the circe integration + circe-generic (for codec auto-derivation) to your dependencies:

libraryDependencies ++= Seq(
"com.softwaremill.sttp.client4" %% "core" % "4.x.y",
"com.softwaremill.sttp.client4" %% "circe" % "4.x.y",
"io.circe" %% "circe-generic" % "0.14.x"
)

Then pick an endpoint from one of the generated *Endpoints.scala files and supply its required auth, path, query, header, or body parameters:

import sttp.client4._
import sttp.model.Uri
import com.example.api.client.users.UsersEndpoints
import com.example.api.client.common.User

val backend = DefaultSyncBackend()
val base = uri"https://api.example.com"

val req = UsersEndpoints.listUsers(base, bearerAuthToken = "jwt...")(page = Some(1))
val res = req.send(backend) // Either[ResponseException[String], PaginatedUsers]
res.body match {
case Right(page) => println(page.users)
case Left(ResponseException.DeserializationException(raw, err)) => println(s"bad JSON: $err")
case Left(ResponseException.UnexpectedStatusCode(raw)) => println(s"HTTP error: $raw")
}

The generated code imports sttp.client4._ (compatible with Scala 2.13 and 3.3+).

Caveats

  • Only the first SecurityScheme's credentials become function parameters. Endpoints using multiple schemes need additional headers supplied manually.
  • Typed bodies/responses require sttp-client4-circe + a circe Encoder/Decoder in scope. The generator emits import io.circe.generic.auto._ to auto-derive; replace with semi-auto or hand-written codecs if you need override control.
  • The raw fallback path (non-JSON captured Content-Type, unnamed body schema) takes bodyJson: String — the generator has no opinion on which codec library you use there.
  • Enum values are emitted as plain String. If you want a sealed trait, refine dtos.scala manually after generation.

TypeScript Fetch Client Format

Dependency: "pl.iterators" %% "baklava-tsfetch" % "VERSION" % Test Configuration: Optional — ts-fetch-package-json key in baklavaGenerateConfigs Output: target/baklava/tsfetch/

Generates a plain-TypeScript client library that uses the browser/Node fetch API — no ts-rest, zod, or other runtime dependencies. Every declared endpoint becomes a typed async function that accepts a BaklavaClient plus path/query/header/body parameters and returns a typed Promise<T> for the 2xx response body. Non-2xx responses throw BaklavaHttpError.

Generated Files

  • package.json / tsconfig.json — minimal npm package with a single typescript dev dep
  • src/client.tsBaklavaClient class with baseUrl, pluggable fetch, optional bearer/basic/API-key credentials; plus BaklavaHttpError for failed responses
  • src/common/types.ts — interfaces for types used by two or more route-area modules
  • src/{area}/types.ts — interfaces for types used only within that module
  • src/{area}/endpoints.ts — one async function per endpoint in that module. Modules follow the same path-derived boundaries as the TS-REST and oRPC formats (/users/...users/; a namespace — a version prefix or a grouping of ≥2 named sub-resources like /admin/* — becomes a folder-per-sub-resource: /v1/auctions/...v1/auctions/, /admin/loggersadmin/loggers/).
  • src/index.ts — re-exports every module's endpoints. Per-module types are re-exported under a namespace (Users, V1Auctions, …) to avoid collisions; shared types appear under Common.

Type Distribution

Each named schema is routed based on which modules' endpoints reference it:

  • Used by one modulesrc/{area}/types.ts
  • Used by two or more modulessrc/common/types.ts

Endpoint files import types from the appropriate location (./types, the shared common/types, or the owning module's types). Interface references inside other interfaces follow the same rule, so the output never duplicates a type.

Schema Type Mapping

Baklava SchemaTypeScript
Stringstring
String (enum)"val1" | "val2"
Int, Long, Double, Float, BigDecimalnumber
Booleanboolean
Nullnull
Seq[T], List[T], Vector[T], Set[T], Array[T]InnerType[]
Case class with propertiesNamed interface (re-exported per-module as Users.ClassName / shared as Common.ClassName)
Map[K, V]Record<string, V>
Option[T]Field becomes optional (field?: T)

Configuration

Override the default package.json contents (name, version, dependencies, etc.) by supplying your own:

baklavaGenerateConfigs := Map(
"ts-fetch-package-json" ->
"""
|{
| "name": "@company/api-client",
| "version": "1.0.0",
| "type": "module",
| "main": "dist/index.js",
| "types": "dist/index.d.ts",
| "scripts": { "build": "tsc" },
| "devDependencies": { "typescript": "^5.4.0" }
|}
|""".stripMargin
)

Unset, a minimal default package.json is emitted.

Usage in Frontend

After generation, build and import:

cd target/baklava/tsfetch
pnpm install && pnpm run build
import { BaklavaClient, listUsers, createUser, Users, Common } from "@company/api-client";

const client = new BaklavaClient({
baseUrl: "https://api.example.com",
bearerToken: "jwt-token-here"
});

const page: Users.PaginatedUsers = await listUsers(client);
const newUser: Common.User = await createUser(client, { body: { name: "Alice" } });

Caveats

  • BaklavaClient.authHeaders() only materializes Authorization for bearer/basic/OAuth/OpenID Connect schemes. API-key-in-header schemes are injected per-endpoint based on client.apiKeys; API-key-in-query schemes go through url.searchParams; API-key-in-cookie schemes emit a Cookie header (which browsers may override for cross-origin requests).
  • When an endpoint declares multiple 2xx responses with different body schemas, the return type is a A | B union of all distinct schemas. You can narrow at the call site with typeof / in checks.
  • Responses are decoded as JSON only when the response Content-Type contains application/json. Any other content type falls through to the raw text (cast to the declared return type), so plain-text 2xx responses don't crash the parser.
  • Request bodies are JSON.stringifyd when the captured requestContentType is JSON (or unspecified). For captures with a non-JSON requestContentType, the body is passed through as BodyInit and the generator emits the captured Content-Type header — supply FormData, Blob, URLSearchParams, or a string at the call site.