feat: TypeScript fetch client generator (#87)#92
Conversation
There was a problem hiding this comment.
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-tsfetchSBT subproject with a formatter + generator that writestarget/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.
| | 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; |
| case SchemaType.ArrayType => | ||
| val inner = schema.items.map(tsType).getOrElse("unknown") | ||
| s"$inner[]" |
| 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 |
|
|
||
| ### 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). |
| 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", |
| 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)) |
| // Headers: auth + per-request declared | ||
| val headerLines = { | ||
| val parts = new scala.collection.mutable.ListBuffer[String] | ||
| parts += " ...client.authHeaders()," |
| // 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}"] } : {}),""" | ||
| } | ||
| } |
| | this.baseUrl = config.baseUrl.replace(/\\/+$$/, ""); | ||
| | this.fetch = config.fetch ?? (globalThis.fetch?.bind(globalThis) as typeof fetch); |
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).
- 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.
There was a problem hiding this comment.
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
tsfetchSBT subproject with a formatter + generator that emitstarget/baklava/tsfetchTypeScript sources and minimal package metadata. - Adds unit tests and integrates
tsfetchinto 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.
| | 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; |
| | `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`) | |
| 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}"] } : {}),""" | ||
| } |
| 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)}) } : {}),""" |
| req.securitySchemes.foreach { scheme => | ||
| scheme.security.apiKeyInHeader.foreach { k => | ||
| parts += s""" ...(client.apiKeys?.["${k.name}"] ? { "${k.name}": client.apiKeys["${k.name}"] } : {}),""" | ||
| } | ||
| } |
| | 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; |
| - 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. |
| 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)}))}") |
| 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)}));""" |
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.
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).
* 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).
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.
be55e3d to
250ceff
Compare
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.
Closes #87.
New
tsfetchmodule that reads the capturedSeq[BaklavaSerializableCall]and emits a standalone TypeScript client library using the browser/NodefetchAPI — no ts-rest, no zod, just plain TS.Output layout (
target/baklava/tsfetch/)package.json/tsconfig.json— minimal npm package shapesrc/client.ts—BaklavaClient(baseUrl, pluggablefetch, bearer / basic / API-key credentials) +BaklavaHttpErrorsrc/types.ts— oneexport interfaceper named object schema, deduped across the whole APIsrc/{tag}.ts— one file per operation tag, oneasync functionper endpoint. Untagged operations land insrc/default.ts.src/index.ts— re-exports everythingPer-endpoint function shape
Each generated function:
BaklavaClientas first arg, typed params object as second{name}path segments viaencodeURIComponentContent-Type: application/jsonandJSON.stringifys the body when a request body schema is presentPromise<T>whereTis the 2xx response body's TS type, orvoidif there's no bodyBaklavaHttpErroron non-2xxExample usage after building:
Schema → TS type mapping
StringstringString(enum)"val1" | "val2"Int,Long,Double,Float,BigDecimalnumberBooleanbooleanSeq/List/Vector/Set/Array[T]InnerType[]T.ClassNameinterfaceOption[T]Tests
default.tsfallback),operationId→function name,{name}path rewrite, 2xx-schema → return type inference, interface emission, client wiring, and index re-exports.sbt +tsfetch/testpasses on Scala 2.13.18 and 3.3.7.Docs
New section in
docs/output-formats.mdcovering generated files, schema type mapping, configuration, usage, and caveats.Stacking
Independent PR, branches off main. Does not stack on #91 (Postman).