Skip to content

feat: TypeScript fetch client generator (#87)#92

Merged
luksow merged 6 commits intomainfrom
feat/typescript-fetch-generator
Apr 30, 2026
Merged

feat: TypeScript fetch client generator (#87)#92
luksow merged 6 commits intomainfrom
feat/typescript-fetch-generator

Conversation

@luksow
Copy link
Copy Markdown
Contributor

@luksow luksow commented Apr 19, 2026

Closes #87.

New tsfetch module that reads the captured Seq[BaklavaSerializableCall] and emits a standalone TypeScript client library using the browser/Node fetch API — no ts-rest, no zod, just plain TS.

Output layout (target/baklava/tsfetch/)

  • package.json / tsconfig.json — minimal npm package shape
  • src/client.tsBaklavaClient (baseUrl, pluggable fetch, bearer / basic / API-key credentials) + BaklavaHttpError
  • src/types.ts — one export interface per named object schema, deduped across the whole API
  • src/{tag}.ts — one file per operation tag, one async function per endpoint. Untagged operations land in src/default.ts.
  • src/index.ts — re-exports everything

Per-endpoint function shape

Each generated function:

  • Takes BaklavaClient as first arg, typed params object as second
  • Fills {name} path segments via encodeURIComponent
  • Sets query params from the typed params object
  • Attaches Content-Type: application/json and JSON.stringifys the body when a request body schema is present
  • Returns Promise<T> where T is the 2xx response body's TS type, or void if there's no body
  • Throws BaklavaHttpError on non-2xx

Example usage after building:

import { BaklavaClient, listUsers, createUser, T } from "@company/api-client";

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

const users: T.User[] = await listUsers(client);
const newUser: T.User = await createUser(client, { body: { name: "Alice" } });

Schema → TS type mapping

Baklava Schema TypeScript
String string
String (enum) "val1" | "val2"
Int, Long, Double, Float, BigDecimal number
Boolean boolean
Seq/List/Vector/Set/Array[T] InnerType[]
Named case class T.ClassName interface
Option[T] Field becomes optional

Tests

  • 9 unit tests cover file layout, tag grouping (incl. default.ts fallback), operationId→function name, {name} path rewrite, 2xx-schema → return type inference, interface emission, client wiring, and index re-exports.
  • sbt +tsfetch/test passes on Scala 2.13.18 and 3.3.7.

Docs

New section in docs/output-formats.md covering generated files, schema type mapping, configuration, usage, and caveats.

Stacking

Independent PR, branches off main. Does not stack on #91 (Postman).

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new tsfetch generator module that emits a standalone TypeScript client library (fetch-based) from captured Seq[BaklavaSerializableCall], and wires it into docs + gold tests + build/CI packaging.

Changes:

  • Introduce baklava-tsfetch SBT subproject with a formatter + generator that writes target/baklava/tsfetch/{package.json,tsconfig.json,src/*}.
  • Add unit tests and ComprehensiveGoldSpec coverage + checked-in golden outputs for the new format.
  • Update documentation, build, and CI to include the new module/output.

Reviewed changes

Copilot reviewed 19 out of 20 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
tsfetch/src/main/scala/pl/iterators/baklava/tsfetch/BaklavaDslFormatterTsFetch.scala New formatter that writes static files and invokes the generator.
tsfetch/src/main/scala/pl/iterators/baklava/tsfetch/BaklavaTsFetchGenerator.scala Core TS codegen (client/types/tag files/index).
tsfetch/src/main/scala/pl/iterators/baklava/tsfetch/BaklavaTsFetchFiles.scala Static package.json / tsconfig.json boilerplate.
tsfetch/src/test/scala/pl/iterators/baklava/tsfetch/BaklavaTsFetchGeneratorSpec.scala Unit tests for layout and selected codegen behavior.
openapi/src/test/scala/pl/iterators/baklava/openapi/ComprehensiveGoldSpec.scala Extend gold test to generate + compare tsfetch output.
openapi/src/test/resources/gold/tsfetch/package.json Golden tsfetch package.json output.
openapi/src/test/resources/gold/tsfetch/tsconfig.json Golden tsfetch tsconfig output.
openapi/src/test/resources/gold/tsfetch/src/client.ts Golden generated client/error implementation.
openapi/src/test/resources/gold/tsfetch/src/index.ts Golden re-export barrel file.
openapi/src/test/resources/gold/tsfetch/src/types.ts Golden generated interfaces.
openapi/src/test/resources/gold/tsfetch/src/auth.ts Golden generated auth-tag endpoints.
openapi/src/test/resources/gold/tsfetch/src/projects.ts Golden generated projects-tag endpoints.
openapi/src/test/resources/gold/tsfetch/src/system.ts Golden generated system-tag endpoints.
openapi/src/test/resources/gold/tsfetch/src/tasks.ts Golden generated tasks-tag endpoints.
openapi/src/test/resources/gold/tsfetch/src/users.ts Golden generated users-tag endpoints.
openapi/src/test/resources/gold/tsfetch/src/webhooks.ts Golden generated webhooks-tag endpoints.
openapi/src/test/resources/gold/openapi/openapi.yml Adjust gold OpenAPI description text (“all generators”).
docs/output-formats.md Document the new TypeScript fetch output format and usage/config.
build.sbt Add tsfetch project and wire it into aggregate + openapi test deps.
.github/workflows/ci.yml Include tsfetch/target in packaged target artifacts.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +79 to +82
| const h: Record<string, string> = {};
| if (this.bearerToken) h["Authorization"] = `Bearer $${this.bearerToken}`;
| else if (this.basic) h["Authorization"] = `Basic $${btoa(`$${this.basic.username}:$${this.basic.password}`)}`;
| return h;
Comment on lines +291 to +293
case SchemaType.ArrayType =>
val inner = schema.items.map(tsType).getOrElse("unknown")
s"$inner[]"
Comment on lines +91 to +103
it("index.ts re-exports client and each tag module") {
val indexTs = generateAndRead(
"src/index.ts",
Seq(
getCall("/users", tag = Some("Users"), operationId = Some("listUsers")),
getCall("/projects", tag = Some("Projects"), operationId = Some("listProjects"))
)
)
indexTs should include("""export * from "./client";""")
indexTs should include("""export * from "./users";""")
indexTs should include("""export * from "./projects";""")
}
}
*/
private[tsfetch] class BaklavaTsFetchGenerator(calls: Seq[BaklavaSerializableCall]) {

/** Every named object schema that appears anywhere in the calls → its TS interface body. Deduplicated by className; later occurrences
Comment thread docs/output-formats.md Outdated

### Caveats

- Only the first `SecurityScheme`'s matching credential is wired into `authHeaders()` automatically. Endpoints using API-key-in-query or API-key-in-cookie schemes need to be set manually (via `client.apiKeys` or by passing an extra header parameter).
Comment on lines +5 to +9
export async function health(_client: BaklavaClient): Promise<T.HealthResponse> {
const url = new URL(`${client.baseUrl}/health`);
let __ret!: T.HealthResponse;
const res = await client.fetch(url.toString(), {
method: "GET",
Comment on lines +175 to +181
val returnType = tsReturnType(endpointCalls)
val sigParams =
if (paramFields.isEmpty) "_client: BaklavaClient"
else s"client: BaklavaClient, params${if (paramsArgOptional) "?" else ""}: $paramsType"

// Build URL: path template substitution + query string
val urlExpr = renderUrlExpression(path, pathParams.map(_.name), queryParams.map(_.name))
Comment on lines +183 to +186
// Headers: auth + per-request declared
val headerLines = {
val parts = new scala.collection.mutable.ListBuffer[String]
parts += " ...client.authHeaders(),"
Comment on lines +195 to +200
// API-key-in-header schemes that don't map to Authorization
req.securitySchemes.foreach { scheme =>
scheme.security.apiKeyInHeader.foreach { k =>
parts += s""" ...(client.apiKeys?.["${k.name}"] ? { "${k.name}": client.apiKeys["${k.name}"] } : {}),"""
}
}
Comment on lines +68 to +69
| this.baseUrl = config.baseUrl.replace(/\\/+$$/, "");
| this.fetch = config.fetch ?? (globalThis.fetch?.bind(globalThis) as typeof fetch);
luksow added a commit that referenced this pull request Apr 19, 2026
Same "per-tag folder" layout as tsfetch (#92). Each named schema is
routed based on how many tags' endpoints reference it:

- Used by exactly one tag → {basePackage}/{tag}/Types.scala
- Used by two or more tags → {basePackage}/common/Types.scala

Endpoint objects emit explicit Scala imports pointing at the right
sub-package, so endpoint method bodies use the short class name.

New output layout (gold regenerated):
  src/main/scala/baklavaclient/
    common/Types.scala          # shared across 2+ tags
    users/
      Types.scala               # single-tag case classes
      Endpoints.scala           # object UsersEndpoints
    projects/
      Types.scala
      Endpoints.scala
    ...

8 unit tests (was 7): added shared-type classification + cross-tag import
coverage. Reserved word list shrunk — 'common' and 'default' are not
Scala keywords, so don't escape them (would have produced backticked
directory names).
luksow added a commit that referenced this pull request Apr 19, 2026
- Zero-param endpoints named the sig arg `_client` while the body still
  referenced `client.*`. Always name it `client`.
- Responses blindly called `JSON.parse` on any 200. Now inspect the
  `Content-Type` first and fall through to raw text when it isn't JSON
  — fixes plain-text 2xx responses (e.g. legacy ack bodies).
- Multiple 2xx schemas for the same endpoint now emit a `A | B` union
  instead of silently dropping all but the first.
- Anonymous object properties that embed named types weren't counted as
  refs of the enclosing class; direct-refs now recurse through
  unnamed objects so they're properly imported.
- Tag folder names now go through a pre-computed collision map with
  stable `-2`, `-3`, … suffixes, so tags that case-fold to the same
  slug no longer overwrite each other.

Verified by compiling the regenerated gold tree with TypeScript 5.8.3
in strict mode (`tsc --noEmit`), no errors.
@luksow luksow requested a review from Copilot April 19, 2026 22:43
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new baklava-tsfetch output format that generates a standalone TypeScript client library using fetch, and wires it into the build/test/docs/golden fixtures so it’s exercised alongside the existing generators.

Changes:

  • Introduces the tsfetch SBT subproject with a formatter + generator that emits target/baklava/tsfetch TypeScript sources and minimal package metadata.
  • Adds unit tests and integrates tsfetch into the comprehensive gold test suite with checked-in golden outputs.
  • Updates documentation, build aggregation, and CI target-directory packaging to include the new module/output.

Reviewed changes

Copilot reviewed 25 out of 26 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
tsfetch/src/main/scala/pl/iterators/baklava/tsfetch/BaklavaDslFormatterTsFetch.scala New formatter entry point writing tsfetch output layout.
tsfetch/src/main/scala/pl/iterators/baklava/tsfetch/BaklavaTsFetchFiles.scala Static boilerplate (package.json, tsconfig.json) for generated client package.
tsfetch/src/main/scala/pl/iterators/baklava/tsfetch/BaklavaTsFetchGenerator.scala Core TS code generator: client, endpoints, and distributed types.ts emission.
tsfetch/src/test/scala/pl/iterators/baklava/tsfetch/BaklavaTsFetchGeneratorSpec.scala Unit tests validating layout, naming, path substitution, type routing, and re-exports.
openapi/src/test/scala/pl/iterators/baklava/openapi/ComprehensiveGoldSpec.scala Runs the new tsfetch generator in the gold suite and asserts golden directory output.
openapi/src/test/resources/gold/tsfetch/package.json Golden fixture for generated package.json.
openapi/src/test/resources/gold/tsfetch/tsconfig.json Golden fixture for generated tsconfig.json.
openapi/src/test/resources/gold/tsfetch/src/client.ts Golden fixture for generated client + error types.
openapi/src/test/resources/gold/tsfetch/src/index.ts Golden fixture for exports (endpoints + type namespaces).
openapi/src/test/resources/gold/tsfetch/src/common/types.ts Golden fixture for shared interfaces.
openapi/src/test/resources/gold/tsfetch/src/auth/types.ts Golden fixture for Auth tag-local types.
openapi/src/test/resources/gold/tsfetch/src/auth/endpoints.ts Golden fixture for Auth endpoints.
openapi/src/test/resources/gold/tsfetch/src/projects/types.ts Golden fixture for Projects tag-local types.
openapi/src/test/resources/gold/tsfetch/src/projects/endpoints.ts Golden fixture for Projects endpoints.
openapi/src/test/resources/gold/tsfetch/src/system/types.ts Golden fixture for System tag-local types.
openapi/src/test/resources/gold/tsfetch/src/system/endpoints.ts Golden fixture for System endpoints.
openapi/src/test/resources/gold/tsfetch/src/tasks/types.ts Golden fixture for Tasks tag-local types.
openapi/src/test/resources/gold/tsfetch/src/tasks/endpoints.ts Golden fixture for Tasks endpoints.
openapi/src/test/resources/gold/tsfetch/src/users/types.ts Golden fixture for Users tag-local types.
openapi/src/test/resources/gold/tsfetch/src/users/endpoints.ts Golden fixture for Users endpoints.
openapi/src/test/resources/gold/tsfetch/src/webhooks/types.ts Golden fixture for Webhooks tag-local types.
openapi/src/test/resources/gold/tsfetch/src/webhooks/endpoints.ts Golden fixture for Webhooks endpoints.
openapi/src/test/resources/gold/openapi/openapi.yml Updates gold description string to reflect “all generators”.
docs/output-formats.md Documents the new TypeScript fetch output format and its files/usage.
build.sbt Adds tsfetch subproject and includes it in aggregation and openapi test deps.
.github/workflows/ci.yml Includes tsfetch/target in release artifact target directory packaging.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +51 to +55
| this.baseUrl = config.baseUrl.replace(/\\/+$$/, "");
| this.fetch = config.fetch ?? (globalThis.fetch?.bind(globalThis) as typeof fetch);
| this.bearerToken = config.bearerToken;
| this.basic = config.basic;
| this.apiKeys = config.apiKeys;
Comment thread docs/output-formats.md Outdated
| `Boolean` | `boolean` |
| `Null` | `null` |
| `Seq[T]`, `List[T]`, `Vector[T]`, `Set[T]`, `Array[T]` | `InnerType[]` |
| Case class with properties | Named `interface` (re-exported as `T.ClassName`) |
Comment on lines +219 to +223
if (bodySchema.exists(!isEmptyBodyInstance(_))) parts += """ "Content-Type": "application/json","""
req.securitySchemes.foreach { scheme =>
scheme.security.apiKeyInHeader.foreach { k =>
parts += s""" ...(client.apiKeys?.["${k.name}"] ? { "${k.name}": client.apiKeys["${k.name}"] } : {}),"""
}
Comment on lines +212 to +216
declaredHeaders.foreach { h =>
val key = h.name
val cond =
if (h.schema.required) s""" "$key": String(params.${tsRawIdent(h.name)}),"""
else s""" ...(params?.${tsRawIdent(h.name)} !== undefined ? { "$key": String(params.${tsRawIdent(h.name)}) } : {}),"""
Comment on lines +220 to +224
req.securitySchemes.foreach { scheme =>
scheme.security.apiKeyInHeader.foreach { k =>
parts += s""" ...(client.apiKeys?.["${k.name}"] ? { "${k.name}": client.apiKeys["${k.name}"] } : {}),"""
}
}
Comment on lines +58 to +62
| authHeaders(): Record<string, string> {
| const h: Record<string, string> = {};
| if (this.bearerToken) h["Authorization"] = `Bearer $${this.bearerToken}`;
| else if (this.basic) h["Authorization"] = `Basic $${btoa(`$${this.basic.username}:$${this.basic.password}`)}`;
| return h;
Comment thread docs/output-formats.md Outdated
Comment on lines +261 to +263
- Only the first `SecurityScheme`'s matching credential is wired into `authHeaders()` automatically. Endpoints using API-key-in-query or API-key-in-cookie schemes need to be set manually (via `client.apiKeys` or by passing an extra header parameter).
- The return type is derived from the first 2xx response's schema. When a single endpoint has multiple 2xx variants with different schemas, only the first wins — matching the OpenAPI generator's existing first-schema-wins policy.
- Non-object response bodies are returned as-is (e.g. plain strings or numbers). The generator calls `JSON.parse` unconditionally, which is safe for JSON-encoded primitives but will fail on raw text response bodies — users serving plain text should parse the response themselves via a custom `fetch` wrapper.
Comment on lines +228 to +230
val bodyLine =
if (bodySchema.exists(!isEmptyBodyInstance(_))) Some(" body: JSON.stringify(params.body),")
else None

private def renderUrlExpression(symbolicPath: String, pathParamNames: Seq[String], queryParamNames: Seq[String]): String = {
val filled = pathParamNames.foldLeft(symbolicPath) { (acc, name) =>
acc.replace(s"{$name}", s"$${encodeURIComponent(String(params.${tsRawIdent(name)}))}")
Comment on lines +369 to +374
val filled = pathParamNames.foldLeft(symbolicPath) { (acc, name) =>
acc.replace(s"{$name}", s"$${encodeURIComponent(String(params.${tsRawIdent(name)}))}")
}
val urlLine = s""" const url = new URL(`$${client.baseUrl}$filled`);"""
val queryLines = queryParamNames.map { name =>
s""" if (params?.${tsRawIdent(name)} !== undefined) url.searchParams.set("$name", String(params.${tsRawIdent(name)}));"""
luksow added a commit that referenced this pull request Apr 20, 2026
Critical:
- Non-identifier param names (e.g. `X-Trace-Id`, `page.size`) now use
  bracket accessors (`params["X-Trace-Id"]`, `params?.["page.size"]`)
  instead of the broken `params.["X-Trace-Id"]`. New helper `tsAccessor`
  handles required and optional-chain contexts uniformly.

Important:
- `...client.authHeaders()` spread only when the operation declares a
  bearer/basic/OAuth/OIDC scheme; public endpoints no longer leak an
  `Authorization` header.
- `apiKeyInQuery` schemes now set the key via `url.searchParams`;
  `apiKeyInCookie` schemes emit a `Cookie` header. Previously only
  `apiKeyInHeader` was wired.
- Request body serialization honors the captured `requestContentType`:
  JSON captures still go through `JSON.stringify`, non-JSON captures
  pass the body as `BodyInit` and emit the captured Content-Type so
  `FormData`/`Blob`/`URLSearchParams` work.
- Array items whose type is a union (e.g. string enum) are wrapped in
  parentheses: `("a" | "b")[]` instead of the ambiguous `"a" | "b"[]`.
- `directReferencesIn` descends through anonymous object properties so
  named types embedded in inline objects still get imported at the
  outer interface.

Client runtime:
- Constructor throws a descriptive error when no fetch is resolvable
  instead of deferring the failure to the first call.
- Basic-auth base64 now falls back to `Buffer.from(...).toString('base64')`
  when `btoa` is missing (Node < 18, edge runtimes).

Docs:
- Rewrote the Caveats section to match actual behavior (auth-header
  gating, response-union return types, Content-Type-aware decoding,
  Content-Type-aware body serialization).
- Corrected the type-namespace row in the schema table.

Tests:
- Added regression coverage for zero-param client name, bracket-access
  emission, conditional auth headers, apiKey-in-query/cookie, array
  union parens, and non-JSON body passthrough.

Verified by `tsc --noEmit` (TypeScript 5.8.3, strict mode) against the
regenerated gold tree.
luksow added a commit that referenced this pull request Apr 21, 2026
Same "per-tag folder" layout as tsfetch (#92). Each named schema is
routed based on how many tags' endpoints reference it:

- Used by exactly one tag → {basePackage}/{tag}/Types.scala
- Used by two or more tags → {basePackage}/common/Types.scala

Endpoint objects emit explicit Scala imports pointing at the right
sub-package, so endpoint method bodies use the short class name.

New output layout (gold regenerated):
  src/main/scala/baklavaclient/
    common/Types.scala          # shared across 2+ tags
    users/
      Types.scala               # single-tag case classes
      Endpoints.scala           # object UsersEndpoints
    projects/
      Types.scala
      Endpoints.scala
    ...

8 unit tests (was 7): added shared-type classification + cross-tag import
coverage. Reserved word list shrunk — 'common' and 'default' are not
Scala keywords, so don't escape them (would have produced backticked
directory names).
luksow added a commit that referenced this pull request Apr 30, 2026
* feat: Scala sttp-client generator (#22)

New `sttpclient` module that emits a tree of Scala source files
containing sttp-client4 request builders for every documented endpoint.
The generated code is framework-agnostic — each endpoint is a `def`
returning `Request[Either[String, String]]` that users .send(backend)
with any sttp backend they like. No opinion on effect type, no opinion
on JSON library.

Output layout (target/baklava/sttpclient/):
- README.md — usage overview
- src/main/scala/{package}/Types.scala — case classes for named schemas
- src/main/scala/{package}/{Tag}Endpoints.scala — one object per tag,
  one def per endpoint. Untagged operations → DefaultEndpoints.scala.

Each generated def takes path params (required positional), query +
header params (required or Option[T] = None), an optional bodyJson:
String (users supply pre-serialized JSON), security credentials from
the first SecurityScheme, and a baseUri: sttp.model.Uri trailing arg.

Schema mapping: String/UUID/Int/Long/Float/Double/BigDecimal/Boolean,
Seq[T] for all collection types, case classes for named objects,
Option[T] = None for optional fields. String-typed enums are emitted as
plain String (users refine to sealed traits manually if desired).

Config:
- sttp-client-package — override the emitted package name
  (defaults to 'baklavaclient')

Tests: 7 unit tests (file layout, tag grouping, operationId → def name,
path interpolation, case-class emission, package config, bearer auth
wiring). Gold integration in ComprehensiveGoldSpec with 7 generated
files + Types.scala covering the full fixture API.

* refactor: per-tag sub-packages + shared common for sttp-client generator

Same "per-tag folder" layout as tsfetch (#92). Each named schema is
routed based on how many tags' endpoints reference it:

- Used by exactly one tag → {basePackage}/{tag}/Types.scala
- Used by two or more tags → {basePackage}/common/Types.scala

Endpoint objects emit explicit Scala imports pointing at the right
sub-package, so endpoint method bodies use the short class name.

New output layout (gold regenerated):
  src/main/scala/baklavaclient/
    common/Types.scala          # shared across 2+ tags
    users/
      Types.scala               # single-tag case classes
      Endpoints.scala           # object UsersEndpoints
    projects/
      Types.scala
      Endpoints.scala
    ...

8 unit tests (was 7): added shared-type classification + cross-tag import
coverage. Reserved word list shrunk — 'common' and 'default' are not
Scala keywords, so don't escape them (would have produced backticked
directory names).

* fix: emit sttp-client4-compatible code (#93)

Three bugs in the generator emitted Scala that wouldn't compile against
sttp-client4:

- Path params interpolation: `addPath("users", "$userId")` → `addPath("users", s"$userId")`.
  The literal-string form passed `"$userId"` as a path segment, not the value.
- Optional query params used `Option.when(...)("k" -> v)` which isn't a
  valid `Uri.addParam` overload. Switched to `addParam("k", opt.map(_.toString))`.
- Optional headers used `Option.when(...)(Header(...))` which isn't a valid
  RequestBuilder.header overload. Switched to `header("k", opt.map(_.toString))`.

Also added:
- apiKeyInCookie support (emits `.cookie(k, v)`).
- apiKeyInQuery support (adds `.addParam(k, v)` to the URI chain).
- `import sttp.client4._` (Scala 2.13 + 3 compatible) instead of `.*`.

Verified by compiling the full regenerated gold tree against
sttp-client4 4.0.9 on both Scala 2.13.18 and 3.3.7.

* fix: drop unused default on test helper to unblock Scala 2.13 CI

* fix: address CR feedback on sttp-client generator (#93)

Critical:
- Security credential parameter names now survive reserved scheme names:
  `securityCredentialParams`/`securityHeaderLines`/`securityQueryLines`
  compose the full identifier (`s.name + "Token"`) before sanitizing, so
  a scheme called `type` produces a valid `typeToken: String` rather
  than the syntactically invalid `` `type`Token ``.

Important:
- `.contentType(...)` now honors the captured request content-type when
  every call on the endpoint declared the same value; previously every
  body endpoint hard-coded `application/json`. The gold `POST /auth/login`
  now correctly emits `"application/x-www-form-urlencoded"`.
- HTTP verbs outside the well-known sttp set (`GET/POST/PUT/DELETE/PATCH/
  HEAD/OPTIONS`) now fall through to `.method(sttp.model.Method("X"), uri)`
  instead of generating a non-existent `.propfind(...)` / `.purge(...)`
  call on `basicRequest`.

Minor:
- README layout paths now use the filesystem package slashes
  (`src/main/scala/com/example/api/...`) rather than the dot-separated
  form, matching the emitted tree.
- README usage snippet no longer hard-codes `UsersEndpoints.listUsers`
  (which doesn't exist for arbitrary APIs); it now points at the
  generated `*Endpoints.scala` files and asks the user to pick one.
- `cleanSrc()` test helper now deletes the whole `target/baklava/sttpclient`
  directory so README fixtures from a previous run can't mask bugs.
- Scaladoc on `BaklavaDslFormatterSttpClient` now matches the actual
  emitted `Request` type.

Tests:
- Added regressions for reserved-word scheme names, captured-content-type
  body, and non-standard HTTP verbs.

Verified by recompiling the regenerated gold tree against sttp-client4
4.0.9 on both Scala 2.13.18 and 3.3.7.

* fix: scalafmt build.sbt after rebase onto main

* feat: rename sttp-client output files + emit typed circe bodies/responses (#93)

File renames (per code review feedback):
- `Types.scala` → `dtos.scala` (per-tag and shared `common/`).
- `Endpoints.scala` → `{Tag}Endpoints.scala` — filename matches object name.
  Untagged operations land in `default/DefaultEndpoints.scala`.

Typed bodies and responses via sttp-client4-circe:
- When a request body schema is a named case class (and the captured
  Content-Type is JSON-ish), the endpoint takes `body: SomeRequest`
  directly and serializes via `body.asJson.noSpaces` (+ sets
  `Content-Type: application/json`).
- When the 2xx response schema is a named case class (or Seq of one),
  the endpoint uses `.response(asJson[T])` and returns
  `Request[Either[ResponseException[String], T]]`.
- `sttp.client4.circe._`, `io.circe.generic.auto._`, and `io.circe.syntax._`
  (for `.asJson`) are imported conditionally per-file.
- Non-JSON captures (multipart, form-urlencoded, plain text) or
  anonymous-object body schemas keep the raw `bodyJson: String` input
  and `Either[String, String]` response, so those endpoints stay
  usable without circe.

Tests:
- Existing suite updated for the new filenames.
- New regressions for the typed-body/typed-response code path and for
  the untyped fallback.

Verified:
- `sbt +sttpclient/test` + ComprehensiveGoldSpec pass.
- Regenerated gold tree compiles against sttp-client4 4.0.9 + circe
  4.0.9 + circe-generic 0.14.15 on Scala 2.13.18.
- End-to-end harness exercises typed bodies/responses against a local
  echo server — `HealthResponse`, `PaginatedUsers`, `User`, `Seq[User]`
  all decode from JSON into the generated case classes successfully;
  401 surfaces correctly for missing-bearer calls.

* refactor: put connection-level params (baseUri + auth) first in sttp-client endpoint signatures (#93)

Generated `def`s now emit parameters in this order:

  baseUri, <auth credentials>, path params, query params, headers, body

instead of the previous "per-call first, connection-level last" layout.
`baseUri` and security credentials are typically configured once per
session and don't vary between calls; fronting them puts the call-site
emphasis on the varying inputs (path/query/body) and makes partial
application patterns viable:

  val getWith = UsersEndpoints.getUser(base, token, _: UUID)
  val u1      = getWith(uid1)

Low-risk change: named arguments at the call site keep working, and no
existing tests asserted parameter ordering. Gold tree regenerated and
verified against sttp-client4-circe 4.0.9 on Scala 2.13.18, plus the
end-to-end harness against a local echo server still decodes typed
responses correctly.

* fix: wire *InCookie OAuth/OIDC + align scaladocs/docs with typed-mode behavior (#93)

- `oAuth2InCookie` / `openIdConnectInCookie` schemes now emit two
  credential parameters (`{scheme}CookieName`, `{scheme}Token`) and
  `.cookie({scheme}CookieName, {scheme}Token)` wiring. Previously these
  schemes fell through silently, producing generators that didn't
  authenticate. Mirrors the Postman generator's cookie-OAuth approach.
- Class-level scaladocs on both `BaklavaSttpClientGenerator` and
  `BaklavaDslFormatterSttpClient` now describe the actual file layout
  (`dtos.scala`, `{Tag}Endpoints.scala`) and the typed-circe return
  shape.
- Per-val scaladocs inside the generator referenced `Types.scala` in a
  few places; all aligned to `dtos.scala`.
- `ComprehensiveGoldSpec` scaladoc bumped from "four output formats" to
  "five" (sttp-client is the fifth).
- `docs/output-formats.md` Endpoint-Shape bullets had internally
  contradictory info about `baseUri` position; rewritten as an ordered
  list that matches the parameter order the generator actually emits.
- Security-mappings table adds rows for `OAuth2InCookie`/`OpenIdConnectInCookie`
  and makes `MutualTls` a distinct explicitly-not-wired row.

Test:
- New regression confirms cookie-OAuth wiring emits both parameters
  and the `.cookie(cookieName, token)` call.
- 14 unit tests pass under `-Xfatal-warnings`; ComprehensiveGoldSpec
  clean (gold fixture unchanged since Pet Store doesn't exercise cookie
  OAuth).

* fix: tighten typed-mode gates + use captured Content-Type on typed body (#93)

Correctness:
- `uniformTypedResponseSchema` now requires *every* 2xx capture to have a
  typed, non-empty body schema (previously some could be empty), AND
  every successful response to carry a JSON-ish `responseContentType`.
  Fixes the case where an endpoint with mixed JSON + plain-text 2xx
  responses would emit `asJson[T]` and fail to deserialize. The Pet
  Store `POST /webhooks` fixture (202 JSON WebhookAck + 202 plain-text
  ack) now correctly falls back to the raw `Either[String, String]`
  path — gold updated.
- `uniformBodyContentType` now requires *all* captures to declare a
  non-empty `requestContentType` before reporting agreement; previously
  `flatMap` silently dropped missing ones and could produce a false
  "all agree" on a partial sample.
- Typed body `.contentType(...)` now reuses the captured value
  (e.g. `application/vnd.api+json; charset=utf-8`) instead of
  hard-coding `application/json`. Consistent with the raw-body path.

Docs:
- README wording no longer implies circe's implicit `BodySerializer[T]`
  is used for request bodies — the generator actually emits explicit
  `body.asJson.noSpaces`.
- Last remaining `Types.scala` reference in a per-method scaladoc
  aligned to `dtos.scala`.

Tests:
- Typed-response regression now sets `requestContentType`/`responseContentType`
  on the fixture to exercise the new JSON-ish response gate.
- 14 unit tests pass under `-Xfatal-warnings`; full gold tree still
  compiles against sttp-client4-circe 4.0.9 + circe-generic 0.14.15 on
  Scala 2.13.18; e2e harness against the local echo server still
  decodes typed responses correctly.

* fix: sanitize fallback def names + clarify typed-mode docs (#93)

Correctness:
- `functionName` fallback (`method + pascalFromPath`) now wraps the
  derived name in `scalaSafeIdent` and the per-segment pascalization
  strips hyphens/dots/other non-identifier characters. Previously a
  path like `/user-profile` with no operationId would generate an
  uncompilable `def getUser-profile(...)`; now it emits
  `getUserProfile`.

Docs / README wording:
- README section on typed bodies/responses now makes request-body
  typing and response typing independent decisions. Previously the
  wording implied the response was always typed whenever either side
  was, which didn't match actual behavior (typed response requires
  all 2xx captures to be JSON-typed; typed body only requires the
  single declared body schema).
- `docs/output-formats.md` lead paragraph and "Typed bodies and
  responses" section rewritten along the same lines, including the
  captured-Content-Type reuse for typed body serialization.

Tests:
- New regression covers fallback name derivation for `/user-profile`,
  `/v1/users`, and `/api/users.json` — each produces a valid Scala
  identifier.

Verification:
- 15 unit tests pass under `-Xfatal-warnings`; gold scala tree
  unchanged (Pet Store fixture exercises only operationIds, so the
  pascalize fix has no gold-level impact); gold README updated for the
  new wording.

* refactor: curry sttp-client endpoints — connection params first, per-call params second (#93)

Generated `def`s now use a two-list curried signature:

  def getUser(baseUri: Uri, bearerAuthToken: String)(userId: UUID): Request[...]

instead of a single flat list. Splits "session-level" inputs (`baseUri`
plus credentials per the first SecurityScheme) from "per-call" inputs
(path, query, headers, body). Endpoints with no per-call inputs collapse
to a single param list so callers don't have to write trailing `()`.

Why curry instead of just reordering or wrapping in a context class:
- Visually separates connection state from per-call inputs at the
  declaration site.
- Enables clean partial application: `val getOnApi = UsersEndpoints.getUser(base, token) _`
  yields a function over the per-call inputs.
- Keeps credentials as required `String`s — no `Option` plumbing, no
  wrapper case class, no `sys.error` on missing fields. The compiler
  still rejects calls that pass wrong/missing credentials.
- Plays nicely with future extensions (e.g. additional connection-level
  configuration) without changing the per-call shape.

Verification:
- 15 unit tests pass under `-Xfatal-warnings`.
- Regenerated gold tree compiles against sttp-client4 4.0.9 + circe 4.0.9
  + circe-generic 0.14.15 on Scala 2.13.18.
- E2E harness against the local echo server exercises five scenarios
  including the partial-application form (`val authedList = UsersEndpoints.listUsers(base, token) _`)
  and confirms typed `HealthResponse`/`PaginatedUsers`/`User` decoding still works.

Docs:
- README ("Usage" section) and `docs/output-formats.md` "Endpoint Shape" /
  "Usage in a Scala Project" sections updated to reflect the curried
  signature, the auto-collapse for parameterless endpoints, and the
  partial-application pattern.

* fix: address final CR — circe-derivable anon objects + scaladoc *​/ escape (#93)

Two would-bite-real-APIs bugs caught by the final review pass.

1) Anonymous-object fields broke circe derivation in typed mode.
   `scalaType` returned `Map[String, Any]` for any object schema that
   isn't a named case class (e.g. `additionalProperties: true`). When
   that field appeared inside a typed parent, circe's `generic.auto._`
   tried to derive `Encoder/Decoder[Map[String, Any]]` and failed with
   an implicit-not-found error. Fix: emit `Map[String, io.circe.Json]`
   instead — circe ships codecs for `Json` so the parent derives
   cleanly. The Pet Store fixture doesn't exercise this path, but real
   APIs with free-form metadata fields commonly do.

2) `renderScaladoc` didn't sanitize the user-supplied summary /
   description before splicing into the `/** ... */` block. An OpenAPI
   spec with `*/` in any operation description (e.g. a code sample
   illustrating a comment terminator) would close the scaladoc early
   and leak the rest of the text into source — guaranteed parse error.
   Fix: replace `*/` with `* /` in the summary/description before
   interpolation.

Tests:
- New regression for the anonymous-object case verifies the parent
  case class's field type is `Map[String, io.circe.Json]` and that
  the literal `Map[String, Any]` does not appear.
- New regression for the scaladoc escape verifies a poisonous
  description (`Returns data (terminator: */`) survives as
  `Returns data (terminator: * /` and never produces the comment-
  terminator sequence in the generated source.
- 17 unit tests pass under `-Xfatal-warnings`; gold tree still
  compiles against real sttp-client4-circe 4.0.9 + circe-generic
  0.14.15 on Scala 2.13.18 (Pet Store fixture is unaffected — neither
  edge case is exercised by it).
luksow added 6 commits April 30, 2026 16:58
New `tsfetch` module that reads captured Seq[BaklavaSerializableCall] and
emits a standalone TypeScript client library using the browser/Node
`fetch` API — no ts-rest, no zod, just plain TS.

Output layout (target/baklava/tsfetch/):
- package.json / tsconfig.json — minimal npm package shape
- src/client.ts — BaklavaClient (baseUrl, pluggable fetch, bearer/basic/
  apiKey credentials) + BaklavaHttpError
- src/types.ts — one `export interface` per named object schema, deduped
  across the whole API
- src/{tag}.ts — one file per operation tag, one `async function` per
  endpoint. Untagged operations land in src/default.ts.
- src/index.ts — re-exports everything

Each generated function:
- Takes `BaklavaClient` as first arg, typed params object as second
- Fills `{name}` path segments via encodeURIComponent
- Sets query params from the typed params object
- Attaches `Content-Type: application/json` and `JSON.stringify`s the body
  when a request body schema is present
- Returns Promise<T> where T is the 2xx response body's TS type, or void
  if there's no body
- Throws BaklavaHttpError on non-2xx

Schema → TS type mapping covers primitives, enums (as string union),
arrays, Option (as optional field), and named object schemas (as
re-exported T.ClassName interfaces).

Config:
- ts-fetch-package-json — optional override for the generated package.json

Tests: 9 unit tests cover file layout, tag grouping (including the
default.ts fallback), operationId→function name, path-segment rewrite,
2xx-schema → return type inference, interface emission, client wiring,
and index re-exports. All pass on Scala 2.13.18 and 3.3.7.

Docs: new section in docs/output-formats.md covering generated files,
schema type mapping, configuration, usage, and caveats.
Adds BaklavaDslFormatterTsFetch to the afterAll battery alongside
openapi/simple/ts-rest, and freshly regenerated gold under
openapi/src/test/resources/gold/tsfetch/ (9 files, 402 lines across
Auth/Users/Projects/Tasks/Webhooks/System tags from the fixture).

Gold asserts byte-equality on every test run; regeneration via
BAKLAVA_REGEN_GOLD=1. Deterministic output verified by running the
spec twice back-to-back.
Addresses the "god object" concern with a single types.ts. Each named
schema is now routed based on how many tags' endpoints reference it:

- Used by exactly one tag → src/{tag}/types.ts
- Used by two or more tags → src/common/types.ts

Endpoint files import from the appropriate location:
  import type { PaginatedUsers } from "./types";
  import type { ErrorResponse, User } from "../common/types";

Index.ts re-exports each tag's types under a namespace (Auth, Users,
Projects, …) to avoid class-name collisions across tags; shared types
appear under Common.

New output layout (gold regenerated, 15 files under 6 tag folders +
common/):
  src/
    client.ts
    index.ts
    common/types.ts            # ErrorResponse, User (multi-tag)
    users/
      types.ts                 # PaginatedUsers, UpdateUserRequest
      endpoints.ts
    projects/
      types.ts
      endpoints.ts
    ...

10 unit tests (was 9): added explicit shared-type classification and
cross-tag import coverage.
- Zero-param endpoints named the sig arg `_client` while the body still
  referenced `client.*`. Always name it `client`.
- Responses blindly called `JSON.parse` on any 200. Now inspect the
  `Content-Type` first and fall through to raw text when it isn't JSON
  — fixes plain-text 2xx responses (e.g. legacy ack bodies).
- Multiple 2xx schemas for the same endpoint now emit a `A | B` union
  instead of silently dropping all but the first.
- Anonymous object properties that embed named types weren't counted as
  refs of the enclosing class; direct-refs now recurse through
  unnamed objects so they're properly imported.
- Tag folder names now go through a pre-computed collision map with
  stable `-2`, `-3`, … suffixes, so tags that case-fold to the same
  slug no longer overwrite each other.

Verified by compiling the regenerated gold tree with TypeScript 5.8.3
in strict mode (`tsc --noEmit`), no errors.
Critical:
- Non-identifier param names (e.g. `X-Trace-Id`, `page.size`) now use
  bracket accessors (`params["X-Trace-Id"]`, `params?.["page.size"]`)
  instead of the broken `params.["X-Trace-Id"]`. New helper `tsAccessor`
  handles required and optional-chain contexts uniformly.

Important:
- `...client.authHeaders()` spread only when the operation declares a
  bearer/basic/OAuth/OIDC scheme; public endpoints no longer leak an
  `Authorization` header.
- `apiKeyInQuery` schemes now set the key via `url.searchParams`;
  `apiKeyInCookie` schemes emit a `Cookie` header. Previously only
  `apiKeyInHeader` was wired.
- Request body serialization honors the captured `requestContentType`:
  JSON captures still go through `JSON.stringify`, non-JSON captures
  pass the body as `BodyInit` and emit the captured Content-Type so
  `FormData`/`Blob`/`URLSearchParams` work.
- Array items whose type is a union (e.g. string enum) are wrapped in
  parentheses: `("a" | "b")[]` instead of the ambiguous `"a" | "b"[]`.
- `directReferencesIn` descends through anonymous object properties so
  named types embedded in inline objects still get imported at the
  outer interface.

Client runtime:
- Constructor throws a descriptive error when no fetch is resolvable
  instead of deferring the failure to the first call.
- Basic-auth base64 now falls back to `Buffer.from(...).toString('base64')`
  when `btoa` is missing (Node < 18, edge runtimes).

Docs:
- Rewrote the Caveats section to match actual behavior (auth-header
  gating, response-union return types, Content-Type-aware decoding,
  Content-Type-aware body serialization).
- Corrected the type-namespace row in the schema table.

Tests:
- Added regression coverage for zero-param client name, bracket-access
  emission, conditional auth headers, apiKey-in-query/cookie, array
  union parens, and non-JSON body passthrough.

Verified by `tsc --noEmit` (TypeScript 5.8.3, strict mode) against the
regenerated gold tree.
@luksow luksow force-pushed the feat/typescript-fetch-generator branch from be55e3d to 250ceff Compare April 30, 2026 15:04
@luksow luksow merged commit 146698f into main Apr 30, 2026
11 checks passed
@luksow luksow deleted the feat/typescript-fetch-generator branch April 30, 2026 15:25
luksow added a commit that referenced this pull request Apr 30, 2026
Three new generators landed on top of 1.1.x — Postman Collection v2.1
(#91), Scala sttp-client (#93), TypeScript fetch (#92). Minor bump
appropriate (additive; existing generators unchanged).

Tag `v1.2.0` is queued to land on main once this PR is merged; the
typelevel publish workflow picks it up from there.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Generate a standalone typescript-fetch client

2 participants