diff --git a/bun.lock b/bun.lock index a92679d..2476ef3 100644 --- a/bun.lock +++ b/bun.lock @@ -58,6 +58,7 @@ }, "peerDependencies": { "typescript": "^5", + "zod": "^4", }, "optionalPeers": [ "typescript", diff --git a/docs/plans/2026-03-29-zenko-treaty-client.md b/docs/plans/2026-03-29-zenko-treaty-client.md new file mode 100644 index 0000000..eb4c68c --- /dev/null +++ b/docs/plans/2026-03-29-zenko-treaty-client.md @@ -0,0 +1,850 @@ +# Zenko Treaty Client Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Generate an Eden Treaty-like client from Zenko's existing `*.gen.ts` modules, with nested path DX, method-leaf calls, param functions, and strongly typed request/response/error handling. + +**Architecture:** Ship this in two layers. First, extend Zenko's normal `.gen.ts` output with a small `operationMetadata` export so a second-pass generator can work from existing generated modules without reparsing YAML or walking the TypeScript AST. Second, add a shared `zenko/treaty` runtime and a `generateTreatyModule()` emitter that writes `*.treaty.gen.ts`; once that works, wire a one-command CLI path via `zenko treaty ...` and `treatyOutput` config. Keep the MVP intentionally below full Eden parity: JSON requests, query/header typing, nested path params, and discriminated `{ data, error }` are in; WS/SSE/in-process Elysia adapters stay out. + +**Tech Stack:** TypeScript, Zod, Bun tests, tsdown, current Zenko generator, `../eden` Treaty2 as the DX reference, and `../elysia` route-tree types as the type-shape reference. + +--- + +## Plan Review Notes + +- The earlier plan was directionally right, but it was still missing the **bridge** from existing `.gen.ts` files to an Eden-like emitter. The concrete fix is to emit a compact `operationMetadata` object during normal Zenko generation. +- Do **not** start with a separate workspace package. The lower-friction MVP is a new `zenko/treaty` subpath exported from `packages/zenko/package.json` and built from `packages/zenko/src/treaty.ts`. +- Treat **second-pass generation** as the MVP and **one-step generation** as a convenience layer on top. That keeps the feature shippable even if the CLI ergonomics need one extra pass at the end. +- The current `OperationErrors` type loses numeric status codes. To get closer to Eden's narrowing, preserve status-keyed response metadata alongside the existing `errors` bucket instead of trying to reconstruct it later. +- Use these files as implementation references while coding: + - `../elysia/src/types.ts` for `CreateEden`, `ResolvePath`, and how nested `:param` route keys are represented + - `../eden/src/treaty2/types.ts` for `TreatyResponse`, `CreateParams`, and the leaf method signatures + - `../eden/src/treaty2/index.ts` for the proxy runtime and request/response assembly + - `../eden/test/treaty2.test.ts` and `../eden/test/types/treaty2.ts` as the MVP acceptance checklist + +### Task 1: Emit Stable Route and Status Metadata in `.gen.ts` + +**Files:** + +- Modify: `packages/zenko/src/types/operation.ts` +- Modify: `packages/zenko/src/core/operation-parser.ts` +- Modify: `packages/zenko/src/zenko.ts` +- Modify: `packages/zenko/src/core/__tests__/operation-parser.test.ts` +- Modify: `packages/zenko/src/__tests__/tictactoe.test.ts` + +**Step 1: Write the failing parser and generator tests** + +Add one parser-level test that proves Zenko preserves status-keyed response information, and one generator-level test that proves it emits metadata into the generated module. + +```typescript +test("preserves success and error response status maps", () => { + const operations = parseOperations(spec, new Map()) + const getSquare = operations.find( + (operation) => operation.operationId === "getSquare" + ) + + expect(getSquare).toMatchObject({ + operationId: "getSquare", + path: "/board/{row}/{column}", + method: "get", + successResponses: { "200": "mark" }, + errorResponses: { "400": "errorMessage" }, + }) +}) + +test("emits operationMetadata with path and status maps", () => { + const result = generate(specYaml) + + expect(result).toContain("export const operationMetadata = {") + expect(result).toContain("getSquare: {") + expect(result).toContain('method: "get"') + expect(result).toContain('path: "/board/{row}/{column}"') + expect(result).toContain('successResponses: { "200": "mark" }') + expect(result).toContain('errorResponses: { "400": "errorMessage" }') +}) +``` + +**Step 2: Run the focused tests to verify they fail** + +Run: `bun zenko test src/core/__tests__/operation-parser.test.ts src/__tests__/tictactoe.test.ts` + +Expected: FAIL because `Operation` does not yet expose status maps and the generator does not emit `operationMetadata`. + +**Step 3: Add the metadata to the internal `Operation` model and emit it** + +In `packages/zenko/src/types/operation.ts`, add explicit maps for success and error responses: + +```typescript +export type OperationResponseMap = Record + +export type Operation = { + operationId: string + path: string + method: RequestMethod + pathParams: PathParam[] + queryParams: QueryParam[] + requestType?: string + responseType?: string + successResponses?: OperationResponseMap + errorResponses?: OperationResponseMap + requestHeaders?: RequestHeader[] + errors?: OperationErrorGroup + security?: SecurityRequirement[] +} +``` + +In `packages/zenko/src/core/operation-parser.ts`, keep the existing `responseType` / `errors` output for backwards compatibility, but also return the raw numeric maps: + +```typescript +function getResponseTypes(...) { + const successCodes = new Map() + const errorEntries: Array<{ code: string; schema: any }> = [] + + // existing logic stays + + const successResponses = Object.fromEntries(successCodes) + const errorResponses = Object.fromEntries( + errorEntries.map(({ code, schema }) => [ + code, + typeof schema === "string" + ? schema + : resolveResponseType(schema, `${capitalize(toCamelCase(operationId))}Status${code}`, nameMap), + ]) + ) + + return { + successResponse, + errors, + successResponses: Object.keys(successResponses).length ? successResponses : undefined, + errorResponses: Object.keys(errorResponses).length ? errorResponses : undefined, + } +} +``` + +In `packages/zenko/src/zenko.ts`, emit the metadata after the existing operation objects: + +```typescript +output.push("// Operation Metadata") +output.push("export const operationMetadata = {") + +for (const op of operations) { + const operationId = toCamelCase(op.operationId) + output.push(` ${formatPropertyName(operationId)}: {`) + output.push(` method: ${JSON.stringify(op.method)},`) + output.push(` path: ${JSON.stringify(op.path)},`) + if (op.successResponses) { + output.push(` successResponses: ${JSON.stringify(op.successResponses)},`) + } + if (op.errorResponses) { + output.push(` errorResponses: ${JSON.stringify(op.errorResponses)},`) + } + output.push(" },") +} + +output.push("} as const") +output.push("") +``` + +**Step 4: Run the focused tests again** + +Run: `bun zenko test src/core/__tests__/operation-parser.test.ts src/__tests__/tictactoe.test.ts` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add packages/zenko/src/types/operation.ts packages/zenko/src/core/operation-parser.ts packages/zenko/src/zenko.ts packages/zenko/src/core/__tests__/operation-parser.test.ts packages/zenko/src/__tests__/tictactoe.test.ts +git commit -m "feat: emit treaty metadata from generated operations" +``` + +--- + +### Task 2: Add the Shared `zenko/treaty` Runtime and Type Helpers + +**Files:** + +- Create: `packages/zenko/src/treaty.ts` +- Modify: `packages/zenko/package.json` +- Modify: `packages/zenko/tsdown.config.ts` +- Create: `packages/zenko/src/__tests__/treaty-runtime.test.ts` + +**Step 1: Write the failing runtime tests** + +Create a minimal route table by hand and prove the runtime supports method leaves, path params, JSON bodies, and the `{ data, error }` result envelope. + +```typescript +import { describe, test, expect, mock } from "bun:test" +import { createTreatyClient } from "../treaty" + +const routes = { + board: { + get: { + method: "get", + path: () => "/board", + }, + ":row": { + ":column": { + put: { + method: "put", + path: ({ row, column }: { row: string; column: string }) => + `/board/${row}/${column}`, + }, + }, + }, + }, +} as const + +test("calls GET leaves and returns a success envelope", async () => { + const fetchMock = mock() + global.fetch = fetchMock as unknown as typeof fetch + + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ winner: "." }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ) + + const client = createTreatyClient({ baseUrl: "https://api.test.com", routes }) + const result = await client.board.get() + + expect(fetchMock).toHaveBeenCalledWith( + "https://api.test.com/board", + expect.objectContaining({ method: "GET" }) + ) + expect(result.error).toBeNull() + expect(result.data).toEqual({ winner: "." }) +}) + +test("walks dynamic segments and sends JSON bodies", async () => { + const fetchMock = mock() + global.fetch = fetchMock as unknown as typeof fetch + + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { status: 200 }) + ) + + const client = createTreatyClient({ baseUrl: "https://api.test.com", routes }) + await client.board({ row: "1" })({ column: "2" }).put("X") + + expect(fetchMock).toHaveBeenCalledWith( + "https://api.test.com/board/1/2", + expect.objectContaining({ + method: "PUT", + body: JSON.stringify("X"), + }) + ) +}) +``` + +**Step 2: Run the runtime test to verify it fails** + +Run: `bun zenko test src/__tests__/treaty-runtime.test.ts` + +Expected: FAIL because `packages/zenko/src/treaty.ts` does not exist yet. + +**Step 3: Implement the shared runtime and type algebra** + +Create `packages/zenko/src/treaty.ts` with: + +- `TreatyResult` +- operation inference helpers from `OperationDefinition` +- param-key routing for keys matching `` `:${string}` `` +- a small proxy runtime that accumulates path segments until a terminal HTTP method is called + +Start with this shape: + +```typescript +import { z } from "zod" + +import type { OperationDefinition } from "./types" + +export type TreatyResult = + | { + data: TData + error: null + response: Response + status: number + headers: Headers + } + | { + data: null + error: TError + response: Response + status: number + headers: Headers + } + +type RequestSchema = + T extends OperationDefinition + ? TRequest extends z.ZodTypeAny + ? z.input + : undefined + : never + +type ResponseSchema = + T extends OperationDefinition + ? TResponse extends z.ZodTypeAny + ? z.output + : undefined + : never + +export function createTreatyClient(config: { + baseUrl: string + routes: Record + fetch?: typeof fetch +}) { + return createProxy([], {}, config) +} +``` + +Implement only the MVP rules from `../eden/src/treaty2/index.ts`: + +- `get` / `head`: `(options?)` +- `post` / `put` / `patch` / `delete`: `(body, options?)` +- query string support for plain values and `Date` +- JSON request bodies +- parse JSON responses when `content-type` contains `application/json` +- return `{ data, error, response, status, headers }` +- do **not** add SSE, WS, `onRequest`, `onResponse`, `throwHttpError`, or multipart yet + +**Step 4: Export the runtime as a real package subpath** + +In `packages/zenko/package.json`, add: + +```json +"./treaty": { + "types": "./dist/treaty.d.ts", + "bun": "./src/treaty.ts", + "import": "./dist/treaty.mjs", + "require": "./dist/treaty.cjs" +} +``` + +In `packages/zenko/tsdown.config.ts`, add the new entry: + +```typescript +entry: { + index: "index.ts", + cli: "src/cli.ts", + treaty: "src/treaty.ts", +}, +``` + +**Step 5: Run the runtime test and build** + +Run: `bun zenko test src/__tests__/treaty-runtime.test.ts` + +Expected: PASS + +Run: `bun zenko build` + +Expected: PASS and `dist/treaty.*` is produced + +**Step 6: Commit** + +```bash +git add packages/zenko/src/treaty.ts packages/zenko/package.json packages/zenko/tsdown.config.ts packages/zenko/src/__tests__/treaty-runtime.test.ts +git commit -m "feat: add zenko treaty runtime" +``` + +--- + +### Task 3: Generate `*.treaty.gen.ts` from Existing `.gen.ts` Files + +**Files:** + +- Create: `packages/zenko/src/treaty-generator.ts` +- Create: `packages/zenko/src/utils/treaty-tree.ts` +- Modify: `packages/zenko/index.ts` +- Create: `packages/zenko/src/__tests__/treaty-generator.test.ts` + +**Step 1: Write the failing generator snapshot test** + +Create a temp `.gen.ts` fixture using the existing `tictactoe` generator, then prove the treaty generator emits the nested route tree and imports the new runtime. + +```typescript +test("generates a nested treaty module from operationMetadata", async () => { + const generatedPath = await writeGeneratedTictactoeModule() + const output = await generateTreatyModule({ + inputFile: generatedPath, + importPath: "./tictactoe.gen", + }) + + expect(output).toContain('import { createTreatyClient } from "zenko/treaty"') + expect(output).toContain("export const treatyRoutes = {") + expect(output).toContain('":row": {') + expect(output).toContain('":column": {') + expect(output).toContain("get: getSquare,") + expect(output).toContain("put: putSquare,") +}) +``` + +**Step 2: Run the generator test to verify it fails** + +Run: `bun zenko test src/__tests__/treaty-generator.test.ts` + +Expected: FAIL because `generateTreatyModule()` and the route-tree utility do not exist yet. + +**Step 3: Implement the tree builder** + +In `packages/zenko/src/utils/treaty-tree.ts`, convert `operationMetadata` into nested route keys: + +```typescript +export type TreatyTreeNode = { + [key: string]: TreatyTreeNode | string +} + +export function buildTreatyTree( + metadata: Record +) { + const root: Record = {} + + for (const [operationId, definition] of Object.entries(metadata)) { + const segments = definition.path + .split("/") + .filter(Boolean) + .map((segment) => + segment.startsWith("{") && segment.endsWith("}") + ? `:${segment.slice(1, -1)}` + : segment + ) + + let cursor = root + for (const segment of segments) { + cursor[segment] ??= {} + cursor = cursor[segment] as Record + } + + cursor[definition.method] = operationId + } + + return root +} +``` + +**Step 4: Implement the module generator** + +In `packages/zenko/src/treaty-generator.ts`, load the generated module via `pathToFileURL`, read `operationMetadata`, render the route tree, and emit a factory plus type aliases: + +```typescript +export async function generateTreatyModule(options: { + inputFile: string + importPath: string +}) { + const mod = await import(pathToFileURL(options.inputFile).href) + const metadata = mod.operationMetadata as Record< + string, + { method: string; path: string } + > + const tree = buildTreatyTree(metadata) + + return [ + 'import { createTreatyClient } from "zenko/treaty"', + `import { ${Object.keys(metadata).join(", ")} } from ${JSON.stringify(options.importPath)}`, + "", + renderTreatyRoutes(tree), + "", + "export const createClient = (baseUrl: string, init?: { fetch?: typeof fetch }) =>", + " createTreatyClient({ baseUrl, routes: treatyRoutes, fetch: init?.fetch })", + "", + ].join("\\n") +} +``` + +Render the leaves as references to existing operation exports: + +```typescript +export const treatyRoutes = { + board: { + get: getBoard, + ":row": { + ":column": { + get: getSquare, + put: putSquare, + }, + }, + }, +} as const +``` + +In `packages/zenko/index.ts`, export the generator: + +```typescript +export { generateTreatyModule } from "./src/treaty-generator" +``` + +**Step 5: Run the generator test again** + +Run: `bun zenko test src/__tests__/treaty-generator.test.ts` + +Expected: PASS + +**Step 6: Commit** + +```bash +git add packages/zenko/src/treaty-generator.ts packages/zenko/src/utils/treaty-tree.ts packages/zenko/index.ts packages/zenko/src/__tests__/treaty-generator.test.ts +git commit -m "feat: generate treaty modules from zenko output" +``` + +--- + +### Task 4: Add a CLI Command for Existing Generated Modules + +**Files:** + +- Modify: `packages/zenko/src/cli.ts` +- Modify: `packages/zenko/src/__tests__/cli.test.ts` + +**Step 1: Write the failing CLI tests** + +Add tests for a second-pass command that takes an existing `.gen.ts` file and writes a treaty module. + +```typescript +test("generates a treaty module from an existing .gen.ts file", () => { + const cliPath = path.join(process.cwd(), "src/cli.ts") + const inputFile = path.join(tempDir, "tictactoe.gen.ts") + const outputFile = path.join(tempDir, "tictactoe.treaty.gen.ts") + + writeGeneratedTictactoeModule(inputFile) + + execSync(`bun run ${cliPath} treaty ${inputFile} ${outputFile}`, { + encoding: "utf8", + }) + + const output = fs.readFileSync(outputFile, "utf8") + expect(output).toContain("export const treatyRoutes = {") + expect(output).toContain('import { createTreatyClient } from "zenko/treaty"') +}) + +test("shows treaty usage in --help output", () => { + const cliPath = path.join(process.cwd(), "src/cli.ts") + const output = execSync(`bun run ${cliPath} --help`, { encoding: "utf8" }) + + expect(output).toContain("zenko treaty ") +}) +``` + +**Step 2: Run the CLI tests to verify they fail** + +Run: `bun zenko test src/__tests__/cli.test.ts` + +Expected: FAIL because the CLI does not understand the `treaty` subcommand yet. + +**Step 3: Implement the `treaty` subcommand** + +Refactor `parseArgs()` to capture the command explicitly: + +```typescript +type ParsedArgs = { + showHelp: boolean + strictDates: boolean + strictNumeric: boolean + configPath?: string + command: "generate" | "treaty" + positional: string[] +} +``` + +In `main()`, branch early: + +```typescript +if (parsed.command === "treaty") { + if (parsed.positional.length !== 2) { + printHelp() + process.exit(1) + return + } + + const [inputFile, outputFile] = parsed.positional + await generateTreatySingle({ inputFile, outputFile }) + return +} +``` + +Add a helper: + +```typescript +async function generateTreatySingle(options: { + inputFile: string + outputFile: string +}) { + const resolvedInput = path.resolve(options.inputFile) + const resolvedOutput = path.resolve(options.outputFile) + const output = await generateTreatyModule({ + inputFile: resolvedInput, + importPath: relativeImportPath(resolvedOutput, resolvedInput), + }) + + fs.mkdirSync(path.dirname(resolvedOutput), { recursive: true }) + fs.writeFileSync(resolvedOutput, output) +} +``` + +Update `printHelp()` to include both generation modes. + +**Step 4: Run the CLI tests again** + +Run: `bun zenko test src/__tests__/cli.test.ts` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add packages/zenko/src/cli.ts packages/zenko/src/__tests__/cli.test.ts +git commit -m "feat: add treaty CLI for generated modules" +``` + +--- + +### Task 5: Add the One-Step `openapi -> zenko -> treaty` Path + +**Files:** + +- Modify: `packages/zenko/src/cli.ts` +- Modify: `packages/zenko/zenko-config.schema.json` +- Modify: `packages/zenko/src/__tests__/cli.test.ts` + +**Step 1: Write the failing one-step tests** + +Add config-based coverage first, because it matches the current CLI design and requires the least surface area: + +```typescript +test("supports treatyOutput in config generation", () => { + const cliPath = path.join(process.cwd(), "src/cli.ts") + const configPath = path.join(tempDir, "zenko.config.json") + const outputFile = path.join(tempDir, "tictactoe.gen.ts") + const treatyOutput = path.join(tempDir, "tictactoe.treaty.gen.ts") + + fs.writeFileSync( + configPath, + JSON.stringify({ + schemas: [ + { + input: path.relative(tempDir, tictactoeYamlPath), + output: "tictactoe.gen.ts", + treatyOutput: "tictactoe.treaty.gen.ts", + }, + ], + }) + ) + + execSync(`bun run ${cliPath} --config ${configPath}`, { encoding: "utf8" }) + + expect(fs.existsSync(outputFile)).toBe(true) + expect(fs.existsSync(treatyOutput)).toBe(true) +}) +``` + +If you want single-run positional support too, add one more test for: + +```typescript +execSync( + `bun run ${cliPath} ${tictactoeYamlPath} ${outputFile} --treaty-output ${treatyOutput}`, + { encoding: "utf8" } +) +``` + +**Step 2: Run the CLI tests to verify they fail** + +Run: `bun zenko test src/__tests__/cli.test.ts` + +Expected: FAIL because `treatyOutput` is not part of the CLI/config contract yet. + +**Step 3: Implement `treatyOutput` in config and optionally in single-run mode** + +Extend the config type in `packages/zenko/src/cli.ts`: + +```typescript +type CliConfigEntry = { + input: string + output: string + treatyOutput?: string + strictDates?: boolean + strictNumeric?: boolean + dateTimeOffset?: boolean | string[] + types?: TypesConfig + operationIds?: string[] + openEnums?: boolean | string[] | EnumConfig +} +``` + +After `generateSingle()` writes the main `.gen.ts` file, call the treaty generator when configured: + +```typescript +await generateSingle(...) + +if (entry.treatyOutput) { + await generateTreatySingle({ + inputFile: outputFile, + outputFile: resolvePath(entry.treatyOutput, baseDir), + }) +} +``` + +Update `packages/zenko/zenko-config.schema.json` to document the field: + +```json +"treatyOutput": { + "type": "string", + "description": "Optional output path for a Treaty-style client generated from the main Zenko .gen.ts output" +} +``` + +**Step 4: Run the CLI tests again** + +Run: `bun zenko test src/__tests__/cli.test.ts` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add packages/zenko/src/cli.ts packages/zenko/zenko-config.schema.json packages/zenko/src/__tests__/cli.test.ts +git commit -m "feat: support one-step treaty generation" +``` + +--- + +### Task 6: Add End-to-End Validation and Documentation + +**Files:** + +- Create: `packages/zenko/src/__tests__/treaty-integration.test.ts` +- Modify: `README.md` + +**Step 1: Write the failing end-to-end test** + +This test should: + +1. generate `tictactoe.gen.ts` +2. generate `tictactoe.treaty.gen.ts` +3. import the treaty client factory from the emitted module +4. mock `fetch` +5. prove the real generated API feels Eden-like + +Use this test shape: + +```typescript +test("generated treaty client supports nested get and put calls", async () => { + const fetchMock = mock() + global.fetch = fetchMock as unknown as typeof fetch + + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ winner: "." }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ) + + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ winner: "X" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ) + + const { createClient } = await import(pathToFileURL(treatyModulePath).href) + const client = createClient("https://api.test.com") + + const board = await client.board.get() + const updated = await client.board({ row: "1" })({ column: "1" }).put("X") + + expect(board.error).toBeNull() + expect(updated.error).toBeNull() + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + "https://api.test.com/board", + expect.objectContaining({ method: "GET" }) + ) + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + "https://api.test.com/board/1/1", + expect.objectContaining({ + method: "PUT", + body: JSON.stringify("X"), + }) + ) +}) +``` + +**Step 2: Run the integration test to verify it fails** + +Run: `bun zenko test src/__tests__/treaty-integration.test.ts` + +Expected: FAIL until the runtime, generator, and import paths all line up correctly. + +**Step 3: Fix the remaining glue and add README usage** + +Add a short README section showing both flows: + +````md +## Treaty-style client generation + +Generate the base Zenko module: + +```bash +zenko petstore.yaml src/schema/petstore.gen.ts +zenko treaty src/schema/petstore.gen.ts src/schema/petstore.treaty.gen.ts +``` +```` + +Or generate both in one config-driven run: + +```json +{ + "schemas": [ + { + "input": "petstore.yaml", + "output": "src/schema/petstore.gen.ts", + "treatyOutput": "src/schema/petstore.treaty.gen.ts" + } + ] +} +``` + +```` + +Keep the usage example short and aligned with Eden's shape: + +```typescript +const client = createClient("https://api.example.com") + +const pets = await client.pets.get() +const pet = await client.pets({ petId: "123" }).get() +```` + +**Step 4: Run the focused tests and whole-repo verification** + +Run: `bun zenko test src/__tests__/treaty-runtime.test.ts src/__tests__/treaty-generator.test.ts src/__tests__/treaty-integration.test.ts src/__tests__/cli.test.ts` + +Expected: PASS + +Run: `bun check` + +Expected: PASS + +Run: `bun zenko test` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add packages/zenko/src/__tests__/treaty-integration.test.ts README.md +git commit -m "docs: add treaty client usage and end-to-end coverage" +``` + +--- + +## Explicitly Deferred for the First Branch + +- `subscribe` / WebSocket support +- SSE / stream parsing +- multipart and nested file heuristics +- passing an in-process `Elysia` app instance instead of a URL +- `throwHttpError`, hook arrays, and advanced response transforms + +If any of these become hard requirements before implementation starts, split them into a follow-up plan instead of bloating the MVP branch. diff --git a/packages/examples/generate.js b/packages/examples/generate.js index db04cb6..cf79279 100644 --- a/packages/examples/generate.js +++ b/packages/examples/generate.js @@ -1,5 +1,5 @@ import { readFileSync, writeFileSync, mkdirSync } from "fs" -import { dirname, resolve } from "path" +import { dirname, relative, resolve } from "path" import { authApiYamlPath, enumDemoYamlPath, @@ -8,7 +8,7 @@ import { trainTravelYamlPath, fireblocksV2YamlPath, } from "@zenko/specs" -import { generate } from "zenko" +import { generate, generateTreatyModule } from "zenko" const specInputPaths = { "auth-api.yaml": authApiYamlPath, @@ -69,47 +69,86 @@ function generateSchema(inputFile, outputFile, options) { } } -try { - // Generate schemas for both specs - const petstoreSuccess = generateSchema("petstore.yaml", "petstore.gen.ts") - const trainTravelSuccess = generateSchema( - "train-travel.yaml", - "train-travel.gen.ts", - { - operationIds: ["get-stations"], // Include only ~10% of operations (1 of 8) - types: { - optionalType: "nullish", - }, +async function generateTrainTravelTreatyModule() { + try { + const genPath = resolve("./src/schema/train-travel.gen.ts") + const treatyPath = resolve("./src/schema/train-travel.treaty.gen.ts") + const outDir = dirname(treatyPath) + let importPath = relative(outDir, genPath).replace(/\\/g, "/") + if (!importPath.startsWith(".")) { + importPath = `./${importPath}` } - ) - const authApiSuccess = generateSchema("auth-api.yaml", "auth-api.gen.ts") - const tictactoeSuccess = generateSchema("tictactoe.yaml", "tictactoe.gen.ts") - const enumDemoSuccess = generateSchema("enum-demo.yaml", "enum-demo.gen.ts", { - openEnums: ["ProductStatus"], // Make ProductStatus open, Category remains closed - }) + importPath = importPath.replace(/\.tsx?$/, "") + const output = await generateTreatyModule({ + inputFile: genPath, + importPath, + }) + writeFileSync(treatyPath, output) + console.log("✅ Generated train-travel.treaty.gen.ts in src/schema/") + return true + } catch (error) { + console.error( + "❌ Error during treaty generation for train-travel:", + error.message + ) + return false + } +} - const fireblocksSuccess = generateSchema( - "fireblocks-v2.yaml", - "fireblocks-v2.gen.ts", - { - types: { - operationTypeSuffix: "Ops", - }, +;(async () => { + try { + const petstoreSuccess = generateSchema("petstore.yaml", "petstore.gen.ts") + const trainTravelSuccess = generateSchema( + "train-travel.yaml", + "train-travel.gen.ts", + { + operationIds: ["get-stations"], // Include only ~10% of operations (1 of 8) + types: { + optionalType: "nullish", + }, + } + ) + const authApiSuccess = generateSchema("auth-api.yaml", "auth-api.gen.ts") + const tictactoeSuccess = generateSchema( + "tictactoe.yaml", + "tictactoe.gen.ts" + ) + const enumDemoSuccess = generateSchema( + "enum-demo.yaml", + "enum-demo.gen.ts", + { + openEnums: ["ProductStatus"], // Make ProductStatus open, Category remains closed + } + ) + + const fireblocksSuccess = generateSchema( + "fireblocks-v2.yaml", + "fireblocks-v2.gen.ts", + { + types: { + operationTypeSuffix: "Ops", + }, + } + ) + if ( + !petstoreSuccess || + !trainTravelSuccess || + !authApiSuccess || + !tictactoeSuccess || + !enumDemoSuccess || + !fireblocksSuccess + ) { + process.exit(1) + } + + const trainTravelTreatySuccess = await generateTrainTravelTreatyModule() + if (!trainTravelTreatySuccess) { + process.exit(1) } - ) - if ( - !petstoreSuccess || - !trainTravelSuccess || - !authApiSuccess || - !tictactoeSuccess || - !enumDemoSuccess || - !fireblocksSuccess - ) { + + console.log("All schemas generated successfully!") + } catch (error) { + console.error("❌ Error during code generation:", error.message) process.exit(1) } - - console.log("All schemas generated successfully!") -} catch (error) { - console.error("❌ Error during code generation:", error.message) - process.exit(1) -} +})() diff --git a/packages/examples/src/__tests__/train-travel-treaty-fetch.test.ts b/packages/examples/src/__tests__/train-travel-treaty-fetch.test.ts new file mode 100644 index 0000000..b91ce3b --- /dev/null +++ b/packages/examples/src/__tests__/train-travel-treaty-fetch.test.ts @@ -0,0 +1,102 @@ +import { afterEach, describe, expect, it, mock } from "bun:test" +import { createClient } from "~/schema/train-travel.treaty.gen" + +describe("TrainTravel treaty client (fetch)", () => { + const origin = "https://api.test.com" + const originalFetch = global.fetch + + afterEach(() => { + global.fetch = originalFetch + }) + + it("lists stations without query and returns a success envelope", async () => { + const fetchMock = setupFetchMock() + const mockPayload = { + data: [ + { + id: "550e8400-e29b-41d4-a716-446655440000", + name: "Central", + address: "1 Main St", + country_code: "US", + timezone: "America/New_York", + }, + ], + links: { self: "https://api.example.com/stations" }, + } + + fetchMock.mockResolvedValue( + new Response(JSON.stringify(mockPayload), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ) + + const client = createClient(origin, { + fetch: fetchMock as unknown as typeof fetch, + }) + const result = await client.stations.get() + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith( + `${origin}/stations`, + expect.objectContaining({ method: "GET" }) + ) + expect(result.error).toBeNull() + expect(result.data).toBeDefined() + expect(result.data).toMatchObject(mockPayload) + expect(result.status).toBe(200) + }) + + it("appends query params for GET via treaty options", async () => { + const fetchMock = setupFetchMock() + const mockPayload = { data: [], links: {} } + + fetchMock.mockResolvedValue( + new Response(JSON.stringify(mockPayload), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ) + + const client = createClient(origin, { + fetch: fetchMock as unknown as typeof fetch, + }) + const result = await client.stations.get({ + query: { limit: 10, page: 2, country: "DE" }, + }) + + expect(fetchMock).toHaveBeenCalledWith( + `${origin}/stations?limit=10&page=2&country=DE`, + expect.objectContaining({ method: "GET" }) + ) + expect(result.error).toBeNull() + expect(result.data).toEqual(mockPayload) + }) + + it("returns an error envelope for non-OK responses", async () => { + const fetchMock = setupFetchMock() + const errorBody = { message: "Too many requests" } + + fetchMock.mockResolvedValue( + new Response(JSON.stringify(errorBody), { + status: 429, + headers: { "Content-Type": "application/json" }, + }) + ) + + const client = createClient(origin, { + fetch: fetchMock as unknown as typeof fetch, + }) + const result = await client.stations.get() + + expect(result.data).toBeNull() + expect(result.error).toEqual({ status: 429, body: errorBody }) + expect(result.status).toBe(429) + }) +}) + +function setupFetchMock() { + const fetchMock = mock() + global.fetch = fetchMock as unknown as typeof fetch + return fetchMock +} diff --git a/packages/examples/tsconfig.json b/packages/examples/tsconfig.json index e99c8fd..e4c9164 100644 --- a/packages/examples/tsconfig.json +++ b/packages/examples/tsconfig.json @@ -16,6 +16,7 @@ "sourceMap": true, "paths": { "zenko": ["../zenko/index.ts"], + "zenko/treaty": ["../zenko/src/treaty.ts"], "~/*": ["./src/*"] } }, diff --git a/packages/zenko/index.ts b/packages/zenko/index.ts index 30c66dd..9037310 100644 --- a/packages/zenko/index.ts +++ b/packages/zenko/index.ts @@ -11,3 +11,15 @@ export { type OperationDefinition, type SecurityRequirement, } from "./src/types" +export { + generateTreatyModule, + generateTreatyModuleFromMetadata, +} from "./src/treaty-generator" +export { + createTreatyClient, + type LeafCall, + type RouteNode, + type TreatyClient, + type TreatyResult, + type TreatyRoutesConstraint, +} from "./src/treaty" diff --git a/packages/zenko/package.json b/packages/zenko/package.json index 06fdb71..cfbd759 100644 --- a/packages/zenko/package.json +++ b/packages/zenko/package.json @@ -39,15 +39,21 @@ "exports": { ".": { "types": "./dist/index.d.ts", - "bun": "./src/zenko.ts", + "bun": "./dist/index.mjs", "import": "./dist/index.mjs", "require": "./dist/index.cjs" }, "./types": { "types": "./dist/types.d.ts", - "bun": "./src/types.ts", + "bun": "./dist/types.mjs", "import": "./dist/types.mjs", "require": "./dist/types.cjs" + }, + "./treaty": { + "types": "./dist/treaty.d.ts", + "bun": "./dist/treaty.mjs", + "import": "./dist/treaty.mjs", + "require": "./dist/treaty.cjs" } }, "publishConfig": { @@ -69,7 +75,8 @@ "typescript": "5.9.3" }, "peerDependencies": { - "typescript": "^5" + "typescript": "^5", + "zod": "^4" }, "peerDependenciesMeta": { "typescript": { diff --git a/packages/zenko/src/__tests__/__snapshots__/additional-properties.test.ts.snap b/packages/zenko/src/__tests__/__snapshots__/additional-properties.test.ts.snap index a4a2cc8..93c9cc9 100644 --- a/packages/zenko/src/__tests__/__snapshots__/additional-properties.test.ts.snap +++ b/packages/zenko/src/__tests__/__snapshots__/additional-properties.test.ts.snap @@ -175,5 +175,28 @@ export const createLabels: CreateLabelsOperation = { path: paths.createLabels, request: Labels, } as const; + +// Operation Metadata +export const operationMetadata = { + createMetadata: { + method: "post", + path: "/metadata", + successResponses: {"201":"Metadata"}, + }, + getConfig: { + method: "get", + path: "/config", + successResponses: {"200":"Config"}, + }, + updateConfig: { + method: "put", + path: "/config", + successResponses: {"200":"Config"}, + }, + createLabels: { + method: "post", + path: "/labels", + }, +} as const; " `; diff --git a/packages/zenko/src/__tests__/__snapshots__/anyof-combinations.test.ts.snap b/packages/zenko/src/__tests__/__snapshots__/anyof-combinations.test.ts.snap index 3ce6ed1..3b0ac9d 100644 --- a/packages/zenko/src/__tests__/__snapshots__/anyof-combinations.test.ts.snap +++ b/packages/zenko/src/__tests__/__snapshots__/anyof-combinations.test.ts.snap @@ -155,5 +155,20 @@ export const searchItems: SearchItemsOperation = { path: paths.searchItems, response: z.array(SearchResult), } as const; + +// Operation Metadata +export const operationMetadata = { + createContact: { + method: "post", + path: "/contacts", + successResponses: {"201":"Contact"}, + errorResponses: {"400":"undefined"}, + }, + searchItems: { + method: "get", + path: "/search", + successResponses: {"200":"z.array(SearchResult)"}, + }, +} as const; " `; diff --git a/packages/zenko/src/__tests__/__snapshots__/cli.test.ts.snap b/packages/zenko/src/__tests__/__snapshots__/cli.test.ts.snap index 3f05294..aaee18b 100644 --- a/packages/zenko/src/__tests__/__snapshots__/cli.test.ts.snap +++ b/packages/zenko/src/__tests__/__snapshots__/cli.test.ts.snap @@ -111,5 +111,26 @@ export const showPetById: ShowPetByIdOperation = { defaultError: Error, }, } as const; + +// Operation Metadata +export const operationMetadata = { + listPets: { + method: "get", + path: "/pets", + successResponses: {"200":"Pets"}, + errorResponses: {"default":"Error"}, + }, + createPets: { + method: "post", + path: "/pets", + errorResponses: {"default":"Error"}, + }, + showPetById: { + method: "get", + path: "/pets/{petId}", + successResponses: {"200":"Pet"}, + errorResponses: {"default":"Error"}, + }, +} as const; " `; diff --git a/packages/zenko/src/__tests__/__snapshots__/complex-composition.test.ts.snap b/packages/zenko/src/__tests__/__snapshots__/complex-composition.test.ts.snap index ff28b9d..7ac432a 100644 --- a/packages/zenko/src/__tests__/__snapshots__/complex-composition.test.ts.snap +++ b/packages/zenko/src/__tests__/__snapshots__/complex-composition.test.ts.snap @@ -278,5 +278,24 @@ export const validateData: ValidateDataOperation = { request: ComplexValidation, response: ValidateDataResponse, } as const; + +// Operation Metadata +export const operationMetadata = { + createEntity: { + method: "post", + path: "/entities", + successResponses: {"201":"Entity"}, + }, + getResource: { + method: "get", + path: "/resources/{resourceId}", + successResponses: {"200":"Resource"}, + }, + validateData: { + method: "post", + path: "/validate", + successResponses: {"200":"ValidateDataStatus200"}, + }, +} as const; " `; diff --git a/packages/zenko/src/__tests__/__snapshots__/date-enum.test.ts.snap b/packages/zenko/src/__tests__/__snapshots__/date-enum.test.ts.snap index 356cff4..52b81fa 100644 --- a/packages/zenko/src/__tests__/__snapshots__/date-enum.test.ts.snap +++ b/packages/zenko/src/__tests__/__snapshots__/date-enum.test.ts.snap @@ -72,6 +72,15 @@ export const createSomething: CreateSomethingOperation = { internalServerError: CreateSomethingErrorResponse, }, } as const; + +// Operation Metadata +export const operationMetadata = { + createSomething: { + method: "post", + path: "/my-service/{someId}/create", + errorResponses: {"500":"CreateSomethingErrorResponse"}, + }, +} as const; " `; @@ -147,6 +156,15 @@ export const createSomething: CreateSomethingOperation = { internalServerError: CreateSomethingErrorResponse, }, } as const; + +// Operation Metadata +export const operationMetadata = { + createSomething: { + method: "post", + path: "/my-service/{someId}/create", + errorResponses: {"500":"CreateSomethingErrorResponse"}, + }, +} as const; " `; @@ -228,5 +246,14 @@ export const createSomething: CreateSomethingOperation = { internalServerError: CreateSomethingErrorResponse, }, } as const; + +// Operation Metadata +export const operationMetadata = { + createSomething: { + method: "post", + path: "/my-service/{someId}/create", + errorResponses: {"500":"CreateSomethingErrorResponse"}, + }, +} as const; " `; diff --git a/packages/zenko/src/__tests__/__snapshots__/form-data.test.ts.snap b/packages/zenko/src/__tests__/__snapshots__/form-data.test.ts.snap index 7158bf3..d2d433a 100644 --- a/packages/zenko/src/__tests__/__snapshots__/form-data.test.ts.snap +++ b/packages/zenko/src/__tests__/__snapshots__/form-data.test.ts.snap @@ -251,6 +251,39 @@ export const uploadDocument: UploadDocumentOperation = { request: DocumentUpload, response: Document, } as const; + +// Operation Metadata +export const operationMetadata = { + uploadFile: { + method: "post", + path: "/upload", + successResponses: {"201":"UploadResponse"}, + }, + uploadMultipleFiles: { + method: "post", + path: "/upload-multiple", + successResponses: {"201":"MultiUploadResponse"}, + }, + createProfile: { + method: "post", + path: "/profile", + successResponses: {"201":"Profile"}, + }, + submitContactForm: { + method: "post", + path: "/contact", + }, + login: { + method: "post", + path: "/login", + successResponses: {"200":"LoginResponse"}, + }, + uploadDocument: { + method: "post", + path: "/documents", + successResponses: {"201":"Document"}, + }, +} as const; " `; @@ -296,5 +329,38 @@ export const uploadDocument: UploadDocumentOperation = { request: DocumentUpload, response: Document, } as const; + +// Operation Metadata +export const operationMetadata = { + uploadFile: { + method: "post", + path: "/upload", + successResponses: {"201":"UploadResponse"}, + }, + uploadMultipleFiles: { + method: "post", + path: "/upload-multiple", + successResponses: {"201":"MultiUploadResponse"}, + }, + createProfile: { + method: "post", + path: "/profile", + successResponses: {"201":"Profile"}, + }, + submitContactForm: { + method: "post", + path: "/contact", + }, + login: { + method: "post", + path: "/login", + successResponses: {"200":"LoginResponse"}, + }, + uploadDocument: { + method: "post", + path: "/documents", + successResponses: {"201":"Document"}, + }, +} as const; " `; diff --git a/packages/zenko/src/__tests__/__snapshots__/inline-response-array.test.ts.snap b/packages/zenko/src/__tests__/__snapshots__/inline-response-array.test.ts.snap index 01ef5b3..ea4e85f 100644 --- a/packages/zenko/src/__tests__/__snapshots__/inline-response-array.test.ts.snap +++ b/packages/zenko/src/__tests__/__snapshots__/inline-response-array.test.ts.snap @@ -59,5 +59,15 @@ export const bySource: BySourceOperation = { serviceUnavailable: undefined, }, } as const; + +// Operation Metadata +export const operationMetadata = { + bySource: { + method: "get", + path: "/logs", + successResponses: {"200":"z.array(AdminLog)"}, + errorResponses: {"400":"undefined","403":"undefined","404":"undefined","500":"undefined","503":"undefined"}, + }, +} as const; " `; diff --git a/packages/zenko/src/__tests__/__snapshots__/mixed-headers.test.ts.snap b/packages/zenko/src/__tests__/__snapshots__/mixed-headers.test.ts.snap index 577289c..2414757 100644 --- a/packages/zenko/src/__tests__/__snapshots__/mixed-headers.test.ts.snap +++ b/packages/zenko/src/__tests__/__snapshots__/mixed-headers.test.ts.snap @@ -43,5 +43,13 @@ export const createMatrix: CreateMatrixOperation = { path: paths.createMatrix, headers: headers.createMatrix, } as const; + +// Operation Metadata +export const operationMetadata = { + createMatrix: { + method: "post", + path: "/matrix", + }, +} as const; " `; diff --git a/packages/zenko/src/__tests__/__snapshots__/no-response-content.test.ts.snap b/packages/zenko/src/__tests__/__snapshots__/no-response-content.test.ts.snap index 065cdcd..cbe4274 100644 --- a/packages/zenko/src/__tests__/__snapshots__/no-response-content.test.ts.snap +++ b/packages/zenko/src/__tests__/__snapshots__/no-response-content.test.ts.snap @@ -43,5 +43,15 @@ export const PutSomething: PutSomethingOperation = { serviceUnavailable: undefined, }, } as const; + +// Operation Metadata +export const operationMetadata = { + PutSomething: { + method: "put", + path: "/data", + successResponses: {"204":"undefined"}, + errorResponses: {"400":"undefined","403":"undefined","404":"undefined","500":"undefined","503":"undefined"}, + }, +} as const; " `; diff --git a/packages/zenko/src/__tests__/__snapshots__/non-json-responses.test.ts.snap b/packages/zenko/src/__tests__/__snapshots__/non-json-responses.test.ts.snap index 9a4e1cc..3f0d0cb 100644 --- a/packages/zenko/src/__tests__/__snapshots__/non-json-responses.test.ts.snap +++ b/packages/zenko/src/__tests__/__snapshots__/non-json-responses.test.ts.snap @@ -339,5 +339,70 @@ export const getRssFeed: GetRssFeedOperation = { path: paths.getRssFeed, response: GetRssFeedResponse, } as const; + +// Operation Metadata +export const operationMetadata = { + exportUsersCsv: { + method: "get", + path: "/export/users", + successResponses: {"200":"ExportUsersCsvStatus200"}, + }, + exportDataXml: { + method: "get", + path: "/export/data", + successResponses: {"200":"ExportDataXmlStatus200"}, + }, + downloadFile: { + method: "get", + path: "/download/{fileId}", + successResponses: {"200":"DownloadFileStatus200"}, + errorResponses: {"404":"undefined"}, + }, + getDocumentPdf: { + method: "get", + path: "/documents/{documentId}/pdf", + successResponses: {"200":"GetDocumentPdfStatus200"}, + }, + getImage: { + method: "get", + path: "/images/{imageId}", + successResponses: {"200":"GetImageStatus200"}, + }, + getLogs: { + method: "get", + path: "/logs", + successResponses: {"200":"GetLogsStatus200"}, + }, + streamEvents: { + method: "get", + path: "/stream/events", + successResponses: {"200":"StreamEventsStatus200"}, + }, + getReport: { + method: "get", + path: "/reports/{reportId}", + successResponses: {"200":"Report"}, + }, + getHealthText: { + method: "get", + path: "/api/health", + successResponses: {"200":"GetHealthTextStatus200"}, + }, + downloadArchive: { + method: "get", + path: "/download/zip/{archiveId}", + successResponses: {"200":"DownloadArchiveStatus200"}, + }, + getMarkdownDoc: { + method: "get", + path: "/markdown/{docId}", + successResponses: {"200":"GetMarkdownDocStatus200"}, + }, + getRssFeed: { + method: "get", + path: "/feed", + successResponses: {"200":"GetRssFeedStatus200"}, + }, +} as const; " `; diff --git a/packages/zenko/src/__tests__/__snapshots__/oneof-discriminator.test.ts.snap b/packages/zenko/src/__tests__/__snapshots__/oneof-discriminator.test.ts.snap index 2674755..fab3368 100644 --- a/packages/zenko/src/__tests__/__snapshots__/oneof-discriminator.test.ts.snap +++ b/packages/zenko/src/__tests__/__snapshots__/oneof-discriminator.test.ts.snap @@ -149,6 +149,22 @@ export const getVehicle: GetVehicleOperation = { notFound: Error, }, } as const; + +// Operation Metadata +export const operationMetadata = { + createPayment: { + method: "post", + path: "/payments", + successResponses: {"201":"Payment"}, + errorResponses: {"400":"Error"}, + }, + getVehicle: { + method: "get", + path: "/vehicles/{vehicleId}", + successResponses: {"200":"Vehicle"}, + errorResponses: {"404":"Error"}, + }, +} as const; " `; @@ -207,5 +223,9 @@ export const headers = { } as const; // Operation Types -// Operation Objects" +// Operation Objects +// Operation Metadata +export const operationMetadata = { +} as const; +" `; diff --git a/packages/zenko/src/__tests__/__snapshots__/petstore.test.ts.snap b/packages/zenko/src/__tests__/__snapshots__/petstore.test.ts.snap index 138e4ed..0970e18 100644 --- a/packages/zenko/src/__tests__/__snapshots__/petstore.test.ts.snap +++ b/packages/zenko/src/__tests__/__snapshots__/petstore.test.ts.snap @@ -111,6 +111,27 @@ export const showPetById: ShowPetByIdOperation = { defaultError: Error, }, } as const; + +// Operation Metadata +export const operationMetadata = { + listPets: { + method: "get", + path: "/pets", + successResponses: {"200":"Pets"}, + errorResponses: {"default":"Error"}, + }, + createPets: { + method: "post", + path: "/pets", + errorResponses: {"default":"Error"}, + }, + showPetById: { + method: "get", + path: "/pets/{petId}", + successResponses: {"200":"Pet"}, + errorResponses: {"default":"Error"}, + }, +} as const; " `; @@ -225,5 +246,26 @@ export const showPetById: ShowPetByIdOperation = { defaultError: Error, }, } as const; + +// Operation Metadata +export const operationMetadata = { + listPets: { + method: "get", + path: "/pets", + successResponses: {"200":"Pets"}, + errorResponses: {"default":"Error"}, + }, + createPets: { + method: "post", + path: "/pets", + errorResponses: {"default":"Error"}, + }, + showPetById: { + method: "get", + path: "/pets/{petId}", + successResponses: {"200":"Pet"}, + errorResponses: {"default":"Error"}, + }, +} as const; " `; diff --git a/packages/zenko/src/__tests__/__snapshots__/property-metadata.test.ts.snap b/packages/zenko/src/__tests__/__snapshots__/property-metadata.test.ts.snap index fd48289..164b0ff 100644 --- a/packages/zenko/src/__tests__/__snapshots__/property-metadata.test.ts.snap +++ b/packages/zenko/src/__tests__/__snapshots__/property-metadata.test.ts.snap @@ -188,5 +188,34 @@ export const updateSettings: UpdateSettingsOperation = { request: SettingsUpdate, response: Settings, } as const; + +// Operation Metadata +export const operationMetadata = { + createUser: { + method: "post", + path: "/users", + successResponses: {"201":"User"}, + }, + getUser: { + method: "get", + path: "/users/{userId}", + successResponses: {"200":"User"}, + }, + updateUser: { + method: "put", + path: "/users/{userId}", + successResponses: {"200":"User"}, + }, + getSettings: { + method: "get", + path: "/settings", + successResponses: {"200":"Settings"}, + }, + updateSettings: { + method: "put", + path: "/settings", + successResponses: {"200":"Settings"}, + }, +} as const; " `; diff --git a/packages/zenko/src/__tests__/__snapshots__/security-schemes.test.ts.snap b/packages/zenko/src/__tests__/__snapshots__/security-schemes.test.ts.snap index e71a0e3..f3b0feb 100644 --- a/packages/zenko/src/__tests__/__snapshots__/security-schemes.test.ts.snap +++ b/packages/zenko/src/__tests__/__snapshots__/security-schemes.test.ts.snap @@ -139,5 +139,26 @@ export const putSquare: PutSquareOperation = { badRequest: errorMessage, }, } as const; + +// Operation Metadata +export const operationMetadata = { + getBoard: { + method: "get", + path: "/board", + successResponses: {"200":"status"}, + }, + getSquare: { + method: "get", + path: "/board/{row}/{column}", + successResponses: {"200":"mark"}, + errorResponses: {"400":"errorMessage"}, + }, + putSquare: { + method: "put", + path: "/board/{row}/{column}", + successResponses: {"200":"status"}, + errorResponses: {"400":"errorMessage"}, + }, +} as const; " `; diff --git a/packages/zenko/src/__tests__/__snapshots__/selective-operations.test.ts.snap b/packages/zenko/src/__tests__/__snapshots__/selective-operations.test.ts.snap index a7ace82..3a93858 100644 --- a/packages/zenko/src/__tests__/__snapshots__/selective-operations.test.ts.snap +++ b/packages/zenko/src/__tests__/__snapshots__/selective-operations.test.ts.snap @@ -89,5 +89,21 @@ export const showPetById: ShowPetByIdOperation = { defaultError: Error, }, } as const; + +// Operation Metadata +export const operationMetadata = { + listPets: { + method: "get", + path: "/pets", + successResponses: {"200":"Pets"}, + errorResponses: {"default":"Error"}, + }, + showPetById: { + method: "get", + path: "/pets/{petId}", + successResponses: {"200":"Pet"}, + errorResponses: {"default":"Error"}, + }, +} as const; " `; diff --git a/packages/zenko/src/__tests__/__snapshots__/tictactoe.test.ts.snap b/packages/zenko/src/__tests__/__snapshots__/tictactoe.test.ts.snap index 781e869..d6b7327 100644 --- a/packages/zenko/src/__tests__/__snapshots__/tictactoe.test.ts.snap +++ b/packages/zenko/src/__tests__/__snapshots__/tictactoe.test.ts.snap @@ -139,6 +139,27 @@ export const putSquare: PutSquareOperation = { badRequest: errorMessage, }, } as const; + +// Operation Metadata +export const operationMetadata = { + getBoard: { + method: "get", + path: "/board", + successResponses: {"200":"status"}, + }, + getSquare: { + method: "get", + path: "/board/{row}/{column}", + successResponses: {"200":"mark"}, + errorResponses: {"400":"errorMessage"}, + }, + putSquare: { + method: "put", + path: "/board/{row}/{column}", + successResponses: {"200":"status"}, + errorResponses: {"400":"errorMessage"}, + }, +} as const; " `; @@ -281,5 +302,26 @@ export const putSquare: PutSquareOperation = { badRequest: errorMessage, }, } as const; + +// Operation Metadata +export const operationMetadata = { + getBoard: { + method: "get", + path: "/board", + successResponses: {"200":"status"}, + }, + getSquare: { + method: "get", + path: "/board/{row}/{column}", + successResponses: {"200":"mark"}, + errorResponses: {"400":"errorMessage"}, + }, + putSquare: { + method: "put", + path: "/board/{row}/{column}", + successResponses: {"200":"status"}, + errorResponses: {"400":"errorMessage"}, + }, +} as const; " `; diff --git a/packages/zenko/src/__tests__/__snapshots__/train-travel.test.ts.snap b/packages/zenko/src/__tests__/__snapshots__/train-travel.test.ts.snap index 7df19ec..266b2e6 100644 --- a/packages/zenko/src/__tests__/__snapshots__/train-travel.test.ts.snap +++ b/packages/zenko/src/__tests__/__snapshots__/train-travel.test.ts.snap @@ -453,5 +453,55 @@ export const newBooking: NewBookingOperation = { request: NewBookingRequest, security: [{ OAuth2: ["read"] }], } as const; + +// Operation Metadata +export const operationMetadata = { + getStations: { + method: "get", + path: "/stations", + successResponses: {"200":"GetStationsStatus200"}, + errorResponses: {"400":"undefined","401":"undefined","403":"undefined","429":"undefined","500":"undefined"}, + }, + getTrips: { + method: "get", + path: "/trips", + successResponses: {"200":"GetTripsStatus200"}, + errorResponses: {"400":"undefined","401":"undefined","403":"undefined","429":"undefined","500":"undefined"}, + }, + getBookings: { + method: "get", + path: "/bookings", + successResponses: {"200":"GetBookingsStatus200"}, + errorResponses: {"400":"undefined","401":"undefined","403":"undefined","429":"undefined","500":"undefined"}, + }, + createBooking: { + method: "post", + path: "/bookings", + successResponses: {"201":"CreateBookingStatus201"}, + errorResponses: {"400":"undefined","401":"undefined","404":"undefined","409":"undefined","429":"undefined","500":"undefined"}, + }, + getBooking: { + method: "get", + path: "/bookings/{bookingId}", + successResponses: {"200":"GetBookingStatus200"}, + errorResponses: {"400":"undefined","401":"undefined","403":"undefined","404":"undefined","429":"undefined","500":"undefined"}, + }, + deleteBooking: { + method: "delete", + path: "/bookings/{bookingId}", + successResponses: {"204":"undefined"}, + errorResponses: {"400":"undefined","401":"undefined","403":"undefined","404":"undefined","429":"undefined","500":"undefined"}, + }, + createBookingPayment: { + method: "post", + path: "/bookings/{bookingId}/payment", + successResponses: {"200":"CreateBookingPaymentStatus200"}, + errorResponses: {"400":"undefined","401":"undefined","403":"undefined","429":"undefined","500":"undefined"}, + }, + newBooking: { + method: "post", + path: "newBooking", + }, +} as const; " `; diff --git a/packages/zenko/src/__tests__/__snapshots__/webhook.test.ts.snap b/packages/zenko/src/__tests__/__snapshots__/webhook.test.ts.snap index bb50e4b..1b8fb55 100644 --- a/packages/zenko/src/__tests__/__snapshots__/webhook.test.ts.snap +++ b/packages/zenko/src/__tests__/__snapshots__/webhook.test.ts.snap @@ -46,6 +46,14 @@ export const newPet: NewPetOperation = { path: paths.newPet, request: Pet, } as const; + +// Operation Metadata +export const operationMetadata = { + newPet: { + method: "post", + path: "newPet", + }, +} as const; " `; @@ -120,5 +128,18 @@ export const newPet: NewPetOperation = { path: paths.newPet, request: Pet, } as const; + +// Operation Metadata +export const operationMetadata = { + listPets: { + method: "get", + path: "/pets", + successResponses: {"200":"z.array(Pet)"}, + }, + newPet: { + method: "post", + path: "newPet", + }, +} as const; " `; diff --git a/packages/zenko/src/__tests__/__snapshots__/zenko.test.ts.snap b/packages/zenko/src/__tests__/__snapshots__/zenko.test.ts.snap index 9282d14..4fcbb19 100644 --- a/packages/zenko/src/__tests__/__snapshots__/zenko.test.ts.snap +++ b/packages/zenko/src/__tests__/__snapshots__/zenko.test.ts.snap @@ -16,7 +16,11 @@ export const headers = { } as const; // Operation Types -// Operation Objects" +// Operation Objects +// Operation Metadata +export const operationMetadata = { +} as const; +" `; exports[`generate Edge cases handles spec with no components: no-components-spec-output 1`] = ` @@ -54,6 +58,14 @@ export const getTest: GetTestOperation = { method: "get", path: paths.getTest, } as const; + +// Operation Metadata +export const operationMetadata = { + getTest: { + method: "get", + path: "/test", + }, +} as const; " `; @@ -87,7 +99,11 @@ export const headers = { } as const; // Operation Types -// Operation Objects" +// Operation Objects +// Operation Metadata +export const operationMetadata = { +} as const; +" `; exports[`generate Response inference infers string error responses without schemas: error-response-inference 1`] = ` @@ -137,6 +153,16 @@ export const getErrorWithoutSchema: GetErrorWithoutSchemaOperation = { internalServerError: string, }, } as const; + +// Operation Metadata +export const operationMetadata = { + getErrorWithoutSchema: { + method: "get", + path: "/error", + successResponses: {"200":"GetErrorWithoutSchemaStatus200"}, + errorResponses: {"500":"string"}, + }, +} as const; " `; @@ -266,5 +292,26 @@ export const showPetById: ShowPetByIdOperation = { defaultError: Error, }, } as const; + +// Operation Metadata +export const operationMetadata = { + listPets: { + method: "get", + path: "/pets", + successResponses: {"200":"Pets"}, + errorResponses: {"default":"Error"}, + }, + createPets: { + method: "post", + path: "/pets", + errorResponses: {"default":"Error"}, + }, + showPetById: { + method: "get", + path: "/pets/{petId}", + successResponses: {"200":"Pet"}, + errorResponses: {"default":"Error"}, + }, +} as const; " `; diff --git a/packages/zenko/src/__tests__/cli.test.ts b/packages/zenko/src/__tests__/cli.test.ts index c159450..f163af8 100644 --- a/packages/zenko/src/__tests__/cli.test.ts +++ b/packages/zenko/src/__tests__/cli.test.ts @@ -2,7 +2,13 @@ import { describe, test, expect, beforeAll, afterAll } from "bun:test" import { execSync } from "child_process" import * as fs from "fs" import * as path from "path" -import { dateEnumYamlPath, petstoreYamlPath } from "@zenko/specs" +import { + dateEnumYamlPath, + petstoreYamlPath, + tictactoeYamlPath, +} from "@zenko/specs" +import { generate } from "../zenko" +import { parseYaml } from "../utils/yaml" describe("CLI", () => { const tempDir = path.join(process.cwd(), "temp-test") @@ -56,6 +62,7 @@ describe("CLI", () => { expect(output).toContain("Usage:") expect(output).toContain("zenko ") + expect(output).toContain("zenko treaty ") expect(output).toContain("Options:") expect(output).toContain("--strict-dates") expect(output).toContain("--strict-numeric") @@ -90,6 +97,25 @@ describe("CLI", () => { }).toThrow() }) + test("generates treaty module from a Zenko .gen.ts file", () => { + const cliPath = path.join(process.cwd(), "src/cli.ts") + const yaml = fs.readFileSync(tictactoeYamlPath, "utf8") + const spec = parseYaml(yaml) + const genPath = path.join(tempDir, "tictactoe.gen.ts") + fs.writeFileSync(genPath, generate(spec)) + const outputPath = path.join(tempDir, "tictactoe.treaty.gen.ts") + + execSync(`bun run ${cliPath} treaty ${genPath} ${outputPath}`, { + encoding: "utf8", + }) + + const output = fs.readFileSync(outputPath, "utf8") + expect(output).toContain("export const treatyRoutes = {") + expect(output).toContain( + 'import { createTreatyClient, type TreatyClient } from "zenko/treaty"' + ) + }) + test("handles JSON input files", () => { // Create a simple JSON OpenAPI spec const jsonSpec = { @@ -151,6 +177,37 @@ describe("CLI", () => { expect(output).toContain("export const paths =") }) + test("supports treatyOutput in config file", () => { + const cliPath = path.join(process.cwd(), "src/cli.ts") + const petstorePath = petstoreYamlPath + + const configDir = path.join(tempDir, "treaty-config") + const configOutput = path.join(configDir, "api.gen.ts") + const treatyOutput = path.join(configDir, "api.treaty.gen.ts") + fs.mkdirSync(configDir, { recursive: true }) + + const configPath = path.join(configDir, "zenko.config.json") + const config = { + schemas: [ + { + input: path.relative(configDir, petstorePath), + output: "api.gen.ts", + treatyOutput: "api.treaty.gen.ts", + }, + ], + } + fs.writeFileSync(configPath, JSON.stringify(config, null, 2)) + + execSync(`bun run ${cliPath} --config ${configPath}`, { + encoding: "utf8", + }) + + expect(fs.existsSync(configOutput)).toBe(true) + expect(fs.existsSync(treatyOutput)).toBe(true) + const treaty = fs.readFileSync(treatyOutput, "utf8") + expect(treaty).toContain("export const treatyRoutes = {") + }) + test("supports strict flags on single run", () => { const cliPath = path.join(process.cwd(), "src/cli.ts") const strictSpec = { diff --git a/packages/zenko/src/__tests__/tictactoe.test.ts b/packages/zenko/src/__tests__/tictactoe.test.ts index 6e492e3..974d743 100644 --- a/packages/zenko/src/__tests__/tictactoe.test.ts +++ b/packages/zenko/src/__tests__/tictactoe.test.ts @@ -101,6 +101,19 @@ describe("TicTacToe", () => { expect(result).toContain("export type status =") }) + test("emits operationMetadata with path and status maps", () => { + const tictactoeContent = fs.readFileSync(tictactoeYamlPath, "utf8") + const specYaml = parseYaml(tictactoeContent) + const result = generate(specYaml) + + expect(result).toContain("export const operationMetadata = {") + expect(result).toContain("getSquare: {") + expect(result).toContain('method: "get"') + expect(result).toContain('path: "/board/{row}/{column}"') + expect(result).toContain('"200":"mark"') + expect(result).toContain('"400":"errorMessage"') + }) + test("generates path functions with parameters", () => { const tictactoeContent = fs.readFileSync(tictactoeYamlPath, "utf8") const specYaml = parseYaml(tictactoeContent) diff --git a/packages/zenko/src/__tests__/treaty-client-types.test.ts b/packages/zenko/src/__tests__/treaty-client-types.test.ts new file mode 100644 index 0000000..86d4b79 --- /dev/null +++ b/packages/zenko/src/__tests__/treaty-client-types.test.ts @@ -0,0 +1,175 @@ +import { describe, test, expect } from "bun:test" +import { z } from "zod" + +import { createTreatyClient } from "../treaty" +import type { TreatyResult } from "../treaty-types" +import type { OperationDefinition, OperationErrors } from "../types" + +/** Compile-time equality check (fails `tsc` when types diverge). */ +type ExpectEqual = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 + ? true + : false + +describe("TreatyClient type inference", () => { + test("nested static segment + GET infers success data from Zod response", () => { + const Resp = z.object({ id: z.string(), name: z.string() }) + const paths = { + list: () => "/items" as const, + } as const + + type ListOp = OperationDefinition< + "get", + typeof paths.list, + undefined, + typeof Resp, + undefined, + OperationErrors, + undefined + > + + const listItems: ListOp = { + method: "get", + path: paths.list, + response: Resp, + } as const + + const routes = { + catalog: { + items: { + get: listItems, + }, + }, + } as const + + const client = createTreatyClient({ + baseUrl: "https://api.test.com", + routes, + }) + + type GetRet = Awaited> + type Ok = Extract + type Data = Ok["data"] + const _equal: ExpectEqual> = true + expect(_equal).toBe(true) + }) + + test("dynamic :row / :column + GET and PUT (tictactoe-shaped routes)", () => { + const errorMessage = z.string() + const mark = z.enum([".", "X", "O"]) + const status = z.object({ + winner: mark.optional(), + board: z.array(z.array(mark).min(3).max(3)).min(3).max(3).optional(), + }) + + const paths = { + getBoard: () => "/board" as const, + getSquare: ({ row, column }: { row: string; column: string }) => + `/board/${row}/${column}` as const, + putSquare: ({ row, column }: { row: string; column: string }) => + `/board/${row}/${column}` as const, + } as const + + type GetBoardOp = OperationDefinition< + "get", + typeof paths.getBoard, + undefined, + typeof status, + undefined, + OperationErrors, + undefined + > + type GetSquareOp = OperationDefinition< + "get", + typeof paths.getSquare, + undefined, + typeof mark, + undefined, + OperationErrors<{ badRequest: typeof errorMessage }>, + undefined + > + type PutSquareOp = OperationDefinition< + "put", + typeof paths.putSquare, + typeof mark, + typeof status, + undefined, + OperationErrors<{ badRequest: typeof errorMessage }>, + undefined + > + + const getBoard: GetBoardOp = { + method: "get", + path: paths.getBoard, + response: status, + } as const + + const getSquare: GetSquareOp = { + method: "get", + path: paths.getSquare, + response: mark, + errors: { badRequest: errorMessage }, + } as const + + const putSquare: PutSquareOp = { + method: "put", + path: paths.putSquare, + request: mark, + response: status, + errors: { badRequest: errorMessage }, + } as const + + const routes = { + board: { + get: getBoard, + ":row": { + ":column": { + get: getSquare, + put: putSquare, + }, + }, + }, + } as const + + const client = createTreatyClient({ + baseUrl: "https://api.test.com", + routes, + }) + + type BoardGet = Awaited> + type BoardData = Extract["data"] + const _boardData: ExpectEqual> = true + expect(_boardData).toBe(true) + + const cell = client.board({ row: "1" })({ column: "2" }) + type PutRet = Awaited> + type PutOk = Extract + type PutData = PutOk["data"] + const _putData: ExpectEqual> = true + expect(_putData).toBe(true) + }) + + test("non-Zod leaves allow unknown body on mutating methods", async () => { + const routes = { + board: { + put: { + method: "put", + path: () => "/board", + }, + }, + } as const + + const fetchMock = async () => + new Response(null, { status: 204, statusText: "No Content" }) + + const client = createTreatyClient({ + baseUrl: "https://api.test.com", + routes, + fetch: fetchMock as unknown as typeof fetch, + }) + + const p: Promise> = client.board.put("payload") + expect(p).toBeInstanceOf(Promise) + await p + }) +}) diff --git a/packages/zenko/src/__tests__/treaty-generator.test.ts b/packages/zenko/src/__tests__/treaty-generator.test.ts new file mode 100644 index 0000000..e6be3f4 --- /dev/null +++ b/packages/zenko/src/__tests__/treaty-generator.test.ts @@ -0,0 +1,51 @@ +import { describe, test, expect } from "bun:test" +import * as fs from "fs" +import { tictactoeYamlPath } from "@zenko/specs" +import { parseOperations } from "../core/operation-parser" +import type { OpenAPISpec } from "../zenko" +import { toCamelCase } from "../utils/string-utils" +import type { OperationMeta } from "../utils/treaty-tree" +import { parseYaml } from "../utils/yaml" +import { generateTreatyModuleFromMetadata } from "../treaty-generator" + +function metadataFromSpec(spec: OpenAPISpec): Record { + const nameMap = new Map() + if (spec.components?.schemas) { + for (const name of Object.keys(spec.components.schemas)) { + nameMap.set(name, toCamelCase(name)) + } + } + + const metadata: Record = {} + for (const op of parseOperations(spec, nameMap)) { + metadata[toCamelCase(op.operationId)] = { + method: op.method, + path: op.path, + } + } + return metadata +} + +describe("generateTreatyModuleFromMetadata", () => { + test("emits treaty module with nested routes for tictactoe", () => { + const yaml = fs.readFileSync(tictactoeYamlPath, "utf8") + const spec = parseYaml(yaml) as OpenAPISpec + const metadata = metadataFromSpec(spec) + + const output = generateTreatyModuleFromMetadata(metadata, { + importPath: "./tictactoe.gen", + }) + + expect(output).toContain( + 'import { createTreatyClient, type TreatyClient } from "zenko/treaty"' + ) + expect(output).toContain("export const treatyRoutes = {") + expect(output).toContain("export function createClient(") + expect(output).toContain("getBoard") + expect(output).toContain("getSquare") + expect(output).toContain("putSquare") + expect(output).toContain("board: {") + expect(output).toContain("getSquare,") + expect(output).toContain("putSquare,") + }) +}) diff --git a/packages/zenko/src/__tests__/treaty-runtime.test.ts b/packages/zenko/src/__tests__/treaty-runtime.test.ts new file mode 100644 index 0000000..8f2567b --- /dev/null +++ b/packages/zenko/src/__tests__/treaty-runtime.test.ts @@ -0,0 +1,73 @@ +import { describe, test, expect, mock } from "bun:test" +import { createTreatyClient } from "../treaty" + +const routes = { + board: { + get: { + method: "get", + path: () => "/board", + }, + ":row": { + ":column": { + put: { + method: "put", + path: ({ row, column }: { row: string; column: string }) => + `/board/${row}/${column}`, + }, + }, + }, + }, +} as const + +describe("createTreatyClient", () => { + test("calls GET leaves and returns a success envelope", async () => { + const fetchMock = mock() + + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ winner: "." }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ) + + const client = createTreatyClient({ + baseUrl: "https://api.test.com", + routes, + fetch: fetchMock as unknown as typeof fetch, + }) + const result = await client.board.get() + + expect(fetchMock).toHaveBeenCalledWith( + "https://api.test.com/board", + expect.objectContaining({ method: "GET" }) + ) + expect(result.error).toBeNull() + expect(result.data).toEqual({ winner: "." }) + }) + + test("walks dynamic segments and sends raw string bodies", async () => { + const fetchMock = mock() + + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ) + + const client = createTreatyClient({ + baseUrl: "https://api.test.com", + routes, + fetch: fetchMock as unknown as typeof fetch, + }) + await client.board({ row: "1" })({ column: "2" }).put("X") + + expect(fetchMock).toHaveBeenCalledWith( + "https://api.test.com/board/1/2", + expect.objectContaining({ + method: "PUT", + body: "X", + }) + ) + }) +}) diff --git a/packages/zenko/src/cli.ts b/packages/zenko/src/cli.ts index 52d8ad8..5ce69dd 100644 --- a/packages/zenko/src/cli.ts +++ b/packages/zenko/src/cli.ts @@ -9,10 +9,12 @@ import { type TypesConfig, type EnumConfig, } from "./zenko.js" +import { generateTreatyModule } from "./treaty-generator.js" type CliConfigEntry = { input: string output: string + treatyOutput?: string strictDates?: boolean strictNumeric?: boolean dateTimeOffset?: boolean | string[] @@ -34,20 +36,59 @@ type ParsedArgs = { positional: string[] } +type ParsedWithCommand = ParsedArgs & { + command: "generate" | "treaty" +} + +function deriveCommand(parsed: ParsedArgs): ParsedWithCommand { + if (parsed.positional[0] === "treaty") { + return { + ...parsed, + command: "treaty", + positional: parsed.positional.slice(1), + } + } + return { ...parsed, command: "generate" } +} + async function main() { const args = process.argv.slice(2) - const parsed = parseArgs(args) + const parsed = deriveCommand(parseArgs(args)) - if ( - parsed.showHelp || - (!parsed.configPath && parsed.positional.length === 0) - ) { + if (parsed.showHelp) { printHelp() - process.exit(parsed.showHelp ? 0 : 1) + process.exit(0) return } try { + if (parsed.command === "treaty") { + if (parsed.configPath) { + console.error("❌ Error: zenko treaty does not support --config") + process.exit(1) + return + } + if (parsed.positional.length !== 2) { + printHelp() + process.exit(1) + return + } + const [inputFile, outputFile] = parsed.positional + if (!inputFile || !outputFile) { + printHelp() + process.exit(1) + return + } + await generateTreatySingle({ inputFile, outputFile }) + return + } + + if (!parsed.configPath && parsed.positional.length === 0) { + printHelp() + process.exit(1) + return + } + if (parsed.configPath) { await runFromConfig(parsed) } else { @@ -121,6 +162,7 @@ function parseArgs(args: string[]): ParsedArgs { function printHelp() { console.log("Usage:") console.log(" zenko [options]") + console.log(" zenko treaty ") console.log(" zenko --config [options]") console.log("") console.log("Options:") @@ -141,6 +183,35 @@ function printHelp() { ) } +async function generateTreatySingle(options: { + inputFile: string + outputFile: string +}) { + const resolvedInput = path.resolve(options.inputFile) + const resolvedOutput = path.resolve(options.outputFile) + const importPath = relativeImportForTreaty( + path.dirname(resolvedOutput), + resolvedInput + ) + const output = await generateTreatyModule({ + inputFile: resolvedInput, + importPath, + }) + + fs.mkdirSync(path.dirname(resolvedOutput), { recursive: true }) + fs.writeFileSync(resolvedOutput, output, { encoding: "utf8" }) + + console.log(`✅ Generated treaty client in ${resolvedOutput}`) +} + +function relativeImportForTreaty(outDir: string, inputPath: string): string { + let rel = path.relative(outDir, inputPath).replace(/\\/g, "/") + if (!rel.startsWith(".")) { + rel = `./${rel}` + } + return rel.replace(/\.tsx?$/, "") +} + async function runFromConfig(parsed: ParsedArgs) { const configPath = parsed.configPath! const resolvedConfigPath = path.resolve(configPath) @@ -164,6 +235,14 @@ async function runFromConfig(parsed: ParsedArgs) { operationIds: entry.operationIds, openEnums: entry.openEnums, }) + + if (entry.treatyOutput) { + const treatyFile = resolvePath(entry.treatyOutput, baseDir) + await generateTreatySingle({ + inputFile: outputFile, + outputFile: treatyFile, + }) + } } } diff --git a/packages/zenko/src/core/__tests__/operation-parser.test.ts b/packages/zenko/src/core/__tests__/operation-parser.test.ts index 4a7124f..0beab79 100644 --- a/packages/zenko/src/core/__tests__/operation-parser.test.ts +++ b/packages/zenko/src/core/__tests__/operation-parser.test.ts @@ -1,5 +1,9 @@ +import * as fs from "fs" import { describe, expect, it } from "bun:test" +import { tictactoeYamlPath } from "@zenko/specs" import type { OpenAPISpec } from "../../zenko" +import { toCamelCase } from "../../utils/string-utils" +import { parseYaml } from "../../utils/yaml" import { parseOperations } from "../operation-parser" describe("parseOperations", () => { @@ -77,6 +81,8 @@ describe("parseOperations", () => { ]) expect(operation?.requestType).toBeUndefined() expect(operation?.responseType).toBe("pet") + expect(operation?.successResponses).toEqual({ "200": "pet" }) + expect(operation?.errorResponses).toEqual({ "404": "GetPetNotFound" }) expect(operation?.errors).toEqual({ notFound: "GetPetNotFound", }) @@ -155,13 +161,41 @@ describe("parseOperations", () => { const [pathOp, webhookOp] = operations expect(pathOp?.responseType).toBe("log") + expect(pathOp?.successResponses).toEqual({ + "201": "log", + "302": "undefined", + }) expect(pathOp?.errors).toBeUndefined() expect(webhookOp?.path).toBe("onPetStatus") expect(webhookOp?.requestType).toBe("petStatus") expect(webhookOp?.responseType).toBe("undefined") + expect(webhookOp?.successResponses).toEqual({ "204": "undefined" }) + expect(webhookOp?.errorResponses).toEqual({ "500": "error" }) expect(webhookOp?.errors).toEqual({ internalServerError: "error", }) }) + + it("preserves success and error response status maps for tictactoe getSquare", () => { + const yaml = fs.readFileSync(tictactoeYamlPath, "utf8") + const spec = parseYaml(yaml) as OpenAPISpec + const nameMap = new Map() + if (spec.components?.schemas) { + for (const name of Object.keys(spec.components.schemas)) { + nameMap.set(name, toCamelCase(name)) + } + } + + const operations = parseOperations(spec, nameMap) + const getSquare = operations.find((op) => op.operationId === "get-square") + + expect(getSquare).toMatchObject({ + operationId: "get-square", + path: "/board/{row}/{column}", + method: "get", + successResponses: { "200": "mark" }, + errorResponses: { "400": "errorMessage" }, + }) + }) }) diff --git a/packages/zenko/src/core/operation-parser.ts b/packages/zenko/src/core/operation-parser.ts index d3b1e84..afade57 100644 --- a/packages/zenko/src/core/operation-parser.ts +++ b/packages/zenko/src/core/operation-parser.ts @@ -11,6 +11,7 @@ import type { RequestMethod } from "../types" import type { Operation, OperationErrorGroup, + OperationResponseMap, PathParam, QueryParam, RequestHeader, @@ -40,11 +41,12 @@ export function parseOperations( const pathParams = extractPathParams(path) const requestType = getRequestType(operation, nameMap) - const { successResponse, errors } = getResponseTypes( - operation, - (operation as { operationId: string }).operationId, - nameMap - ) + const { successResponse, errors, successResponses, errorResponses } = + getResponseTypes( + operation, + (operation as { operationId: string }).operationId, + nameMap + ) const resolvedParameters = collectParameters(pathItem, operation, spec) const requestHeaders = getRequestHeaders(resolvedParameters) const queryParams = getQueryParams(resolvedParameters) @@ -58,6 +60,8 @@ export function parseOperations( queryParams, requestType, responseType: successResponse, + successResponses, + errorResponses, requestHeaders, errors, security, @@ -76,11 +80,12 @@ export function parseOperations( const path = webhookName const pathParams = extractPathParams(path) const requestType = getRequestType(operation, nameMap) - const { successResponse, errors } = getResponseTypes( - operation, - (operation as { operationId: string }).operationId, - nameMap - ) + const { successResponse, errors, successResponses, errorResponses } = + getResponseTypes( + operation, + (operation as { operationId: string }).operationId, + nameMap + ) const resolvedParameters = collectParameters( webhookItem, operation, @@ -98,6 +103,8 @@ export function parseOperations( queryParams, requestType, responseType: successResponse, + successResponses, + errorResponses, requestHeaders, errors, security, @@ -219,6 +226,8 @@ function getResponseTypes( ): { successResponse?: string errors?: OperationErrorGroup + successResponses?: OperationResponseMap + errorResponses?: OperationResponseMap } { const responses = operation.responses ?? {} const successCodes = new Map() @@ -273,8 +282,64 @@ function getResponseTypes( nameMap ) const errors = buildErrorGroups(errorEntries, operationId, nameMap) + const successResponses = buildSuccessResponsesMap( + successCodes, + operationId, + nameMap + ) + const errorResponses = buildErrorResponsesMap( + errorEntries, + operationId, + nameMap + ) + + return { + successResponse, + errors, + successResponses, + errorResponses, + } +} + +function buildSuccessResponsesMap( + successCodes: Map, + operationId: string, + nameMap?: Map +): OperationResponseMap | undefined { + if (successCodes.size === 0) return undefined + + const map: OperationResponseMap = {} + for (const [code, schema] of successCodes) { + if (typeof schema === "string") { + map[code] = schema + } else { + map[code] = resolveResponseType( + schema, + `${capitalize(toCamelCase(operationId))}Status${code}`, + nameMap + ) + } + } + return map +} + +function buildErrorResponsesMap( + errorEntries: Array<{ code: string; schema: any }>, + operationId: string, + nameMap?: Map +): OperationResponseMap | undefined { + if (errorEntries.length === 0) return undefined - return { successResponse, errors } + const map: OperationResponseMap = {} + for (const { code, schema } of errorEntries) { + const identifier = mapStatusToIdentifier(code) + map[code] = resolveResponseType( + schema, + `${capitalize(toCamelCase(operationId))}${capitalize(identifier)}`, + nameMap + ) + } + return map } function selectSuccessResponse( diff --git a/packages/zenko/src/treaty-generator.ts b/packages/zenko/src/treaty-generator.ts new file mode 100644 index 0000000..87480fa --- /dev/null +++ b/packages/zenko/src/treaty-generator.ts @@ -0,0 +1,58 @@ +import { pathToFileURL } from "node:url" + +import { + buildTreatyRouteTree, + emitTreatyRouteTree, + type OperationMeta, +} from "./utils/treaty-tree" + +export function generateTreatyModuleFromMetadata( + metadata: Record, + options: { importPath: string } +): string { + const tree = buildTreatyRouteTree(metadata) + const exportNames = Object.keys(metadata).sort() + const routeBody = emitTreatyRouteTree(tree) + + const lines: string[] = [ + `import { createTreatyClient, type TreatyClient } from "zenko/treaty";`, + `import { ${exportNames.join(", ")} } from ${JSON.stringify(options.importPath)};`, + "", + "export const treatyRoutes = {", + routeBody, + "};", + "", + "export function createClient(", + " baseUrl: string,", + " init?: { fetch?: typeof fetch }", + "): TreatyClient {", + " return createTreatyClient({", + " baseUrl,", + " routes: treatyRoutes,", + " fetch: init?.fetch,", + " })", + "}", + "", + ] + + return lines.join("\n") +} + +export async function generateTreatyModule(options: { + inputFile: string + importPath: string +}): Promise { + const url = pathToFileURL(options.inputFile).href + const mod = await import(url) + + if (!mod.operationMetadata) { + throw new Error( + `Missing operationMetadata export in ${options.inputFile} — regenerate with the latest Zenko` + ) + } + + const metadata = mod.operationMetadata as Record + return generateTreatyModuleFromMetadata(metadata, { + importPath: options.importPath, + }) +} diff --git a/packages/zenko/src/treaty-infer.ts b/packages/zenko/src/treaty-infer.ts new file mode 100644 index 0000000..3bdeb7a --- /dev/null +++ b/packages/zenko/src/treaty-infer.ts @@ -0,0 +1,111 @@ +import type { z } from "zod" + +import type { + OperationDefinition, + OperationErrors, + RequestMethod, + SecurityRequirement, +} from "./types" +import type { AnyHeaderFn } from "./types" +import type { TreatyResult } from "./treaty-types" + +type AnyOperationDefinition = OperationDefinition< + RequestMethod, + (...args: any[]) => string, + unknown, + unknown, + AnyHeaderFn | undefined, + OperationErrors | undefined, + readonly SecurityRequirement[] | undefined +> + +type TreatyMethodOptions = RequestInit & { + query?: Record + headers?: Record +} + +type InferZodOutput = Schema extends z.ZodType + ? z.infer + : unknown + +type InferZodInput = Schema extends z.ZodType + ? z.input + : unknown + +type SuccessData = + NonNullable extends z.ZodType + ? InferZodOutput> + : unknown + +/** + * Structural check: `Op["method"] extends "get" | "head"` is wrong when `method` is + * typed as `RequestMethod` (union) — it would match the GET branch incorrectly. + */ +export type LeafCall = Op extends { + method: "get" | "head" +} + ? (opts?: { + query?: Record + headers?: Record + }) => Promise>> + : Op extends { request: infer Req } + ? Req extends z.ZodType + ? ( + body: InferZodInput, + init?: TreatyMethodOptions + ) => Promise>> + : ( + body?: unknown, + init?: TreatyMethodOptions + ) => Promise>> + : ( + body?: unknown, + init?: TreatyMethodOptions + ) => Promise>> + +type LeafMethods = { + [K in keyof R as K extends symbol + ? never + : R[K] extends AnyOperationDefinition + ? K + : never]: R[K] extends AnyOperationDefinition ? LeafCall : never +} + +type DynamicParamKey = Extract + +type ParamRecord = K extends `:${infer Name}` + ? { [P in Name]: string | number } + : never + +type DynamicBranch = [DynamicParamKey] extends [never] + ? unknown + : DynamicParamKey extends infer K extends `:${string}` + ? K extends keyof R + ? (params: ParamRecord) => TreatyClient + : never + : unknown + +type StaticSegmentChildren = { + [K in keyof R as K extends symbol + ? never + : K extends `:${string}` + ? never + : R[K] extends AnyOperationDefinition + ? never + : R[K] extends Record + ? K + : never]: R[K] extends Record + ? TreatyClient + : never +} + +/** + * Inferred Eden-style client for a nested `treatyRoutes` object whose leaves are + * {@link OperationDefinition} values (Zod-typed `request` / `response`). + */ +export type TreatyClient = LeafMethods & + StaticSegmentChildren & + DynamicBranch + +/** Permissive input constraint for `createTreatyClient` — inference comes from `routes`. */ +export type TreatyRoutesConstraint = Record diff --git a/packages/zenko/src/treaty-types.ts b/packages/zenko/src/treaty-types.ts new file mode 100644 index 0000000..934e4cd --- /dev/null +++ b/packages/zenko/src/treaty-types.ts @@ -0,0 +1,20 @@ +/** Nested route tree: segments, `:param` keys, and HTTP method leaves (see `isLeaf` in treaty runtime). */ +export type RouteNode = Record + +export type TreatySuccess = { + data: T + error: null + response: Response + status: number + headers: Headers +} + +export type TreatyFailure = { + data: null + error: { status: number; body: unknown } + response: Response + status: number + headers: Headers +} + +export type TreatyResult = TreatySuccess | TreatyFailure diff --git a/packages/zenko/src/treaty.ts b/packages/zenko/src/treaty.ts new file mode 100644 index 0000000..29d6a83 --- /dev/null +++ b/packages/zenko/src/treaty.ts @@ -0,0 +1,256 @@ +import type { TreatyClient, TreatyRoutesConstraint } from "./treaty-infer" +import type { RouteNode, TreatyResult } from "./treaty-types" + +export type { + RouteNode, + TreatyFailure, + TreatyResult, + TreatySuccess, +} from "./treaty-types" +export type { + LeafCall, + TreatyClient, + TreatyRoutesConstraint, +} from "./treaty-infer" + +const HTTP_METHODS = new Set([ + "get", + "post", + "put", + "patch", + "delete", + "options", + "head", + "connect", + "trace", +]) + +type RouteLeaf = { + method: string + path: string | (() => string) | ((params: Record) => string) +} + +function isLeaf(node: unknown): node is RouteLeaf { + if (typeof node !== "object" || node === null) return false + const n = node as Record + return ( + typeof n.method === "string" && + (typeof n.path === "function" || typeof n.path === "string") + ) +} + +function joinUrl(baseUrl: string, path: string): string { + const base = baseUrl.replace(/\/$/, "") + const p = path.startsWith("/") ? path : `/${path}` + return `${base}${p}` +} + +function resolvePath( + path: RouteLeaf["path"], + params: Record +): string { + if (typeof path === "string") return path + if (path.length === 0) { + return (path as () => string)() + } + return (path as (p: Record) => string)(params) +} + +function serializeQueryValue(value: unknown): string { + if (value instanceof Date) return value.toISOString() + if (typeof value === "object" && value !== null) { + return JSON.stringify(value) + } + if ( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" || + typeof value === "bigint" + ) { + return String(value) + } + return "" +} + +function buildQueryString(query: Record): string { + const parts: string[] = [] + for (const [key, value] of Object.entries(query)) { + if (value === undefined || value === null) continue + const v = serializeQueryValue(value) + parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(v)}`) + } + return parts.length ? `?${parts.join("&")}` : "" +} + +async function parseResponseBody(response: Response): Promise { + const contentType = response.headers.get("content-type") ?? "" + if (contentType.includes("application/json")) { + try { + return await response.json() + } catch { + return null + } + } + const text = await response.text() + return text === "" ? null : text +} + +function createRouteProxy(options: { + baseUrl: string + node: RouteNode | RouteLeaf + params: Record + fetchImpl: typeof fetch +}): unknown { + const { baseUrl, node, params, fetchImpl } = options + + return new Proxy(() => {}, { + get(_, prop: string | symbol) { + if (typeof prop === "symbol") return undefined + if (prop === "then") return undefined + + const child = (node as Record)[prop] as unknown + if (child === undefined) return undefined + + if (isLeaf(child)) { + if (!HTTP_METHODS.has(prop)) return undefined + return createLeafCaller({ + baseUrl, + leaf: child, + params, + fetchImpl, + }) + } + + return createRouteProxy({ + baseUrl, + node: child as RouteNode, + params, + fetchImpl, + }) + }, + + apply(_, __, args: unknown[]) { + const arg = args[0] as Record | undefined + if (!arg || typeof arg !== "object") { + throw new TypeError("Expected a path parameter object") + } + + const nodeRecord = node as Record + const dynamicKey = Object.keys(nodeRecord).find((k) => k.startsWith(":")) + if (!dynamicKey) { + throw new TypeError("No dynamic path segment here") + } + + const child = nodeRecord[dynamicKey] + if (child === undefined) { + throw new TypeError(`Missing route segment ${dynamicKey}`) + } + + const merged = { ...params, ...arg } + + if (isLeaf(child)) { + throw new TypeError("Unexpected leaf under dynamic segment") + } + + return createRouteProxy({ + baseUrl, + node: child as RouteNode, + params: merged, + fetchImpl, + }) + }, + }) +} + +function createLeafCaller(options: { + baseUrl: string + leaf: RouteLeaf + params: Record + fetchImpl: typeof fetch +}) { + const { baseUrl, leaf, params, fetchImpl } = options + + const method = leaf.method.toLowerCase() + const upper = method.toUpperCase() + const isGetOrHead = method === "get" || method === "head" + + return async ( + body?: unknown, + init?: RequestInit & { + query?: Record + headers?: Record + } + ): Promise> => { + const pathStr = resolvePath(leaf.path, params) + let url = joinUrl(baseUrl, pathStr) + + const query = isGetOrHead + ? (body as { query?: Record } | undefined)?.query + : init?.query + if (query && typeof query === "object") { + url += buildQueryString(query) + } + + const headers: Record = { + ...(isGetOrHead + ? (body as { headers?: Record } | undefined)?.headers + : init?.headers), + } + + let requestBody: string | ArrayBuffer | undefined + if (!isGetOrHead && body !== undefined) { + headers["content-type"] = headers["content-type"] ?? "application/json" + requestBody = + typeof body === "string" || body instanceof ArrayBuffer + ? body + : JSON.stringify(body) + } + + const { method: _, body: __, ...safeInit } = init ?? {} + + const response = await fetchImpl(url, { + ...safeInit, + method: upper, + headers: + Object.keys(headers).length > 0 ? new Headers(headers) : undefined, + body: isGetOrHead ? undefined : requestBody, + }) + + const resBody = await parseResponseBody(response) + const ok = response.ok + + if (!ok) { + return { + data: null, + error: { status: response.status, body: resBody }, + response, + status: response.status, + headers: response.headers, + } + } + + return { + data: resBody, + error: null, + response, + status: response.status, + headers: response.headers, + } + } +} + +export function createTreatyClient< + const R extends TreatyRoutesConstraint, +>(config: { + baseUrl: string + routes: R + fetch?: typeof fetch +}): TreatyClient { + const fetchImpl = config.fetch ?? globalThis.fetch + return createRouteProxy({ + baseUrl: config.baseUrl, + node: config.routes as RouteNode, + params: {}, + fetchImpl, + }) as TreatyClient +} diff --git a/packages/zenko/src/types/operation.ts b/packages/zenko/src/types/operation.ts index 3fee316..5c3e7c2 100644 --- a/packages/zenko/src/types/operation.ts +++ b/packages/zenko/src/types/operation.ts @@ -25,6 +25,9 @@ export type OperationErrorMap = Record export type OperationErrorGroup = OperationErrorMap +/** Maps HTTP status code string to resolved response type name (Zod schema symbol). */ +export type OperationResponseMap = Record + export type Operation = { operationId: string path: string @@ -33,6 +36,10 @@ export type Operation = { queryParams: QueryParam[] requestType?: string responseType?: string + /** Per-status success response types (2xx, 204, 3xx with empty body). */ + successResponses?: OperationResponseMap + /** Per-status error response types (4xx/5xx). */ + errorResponses?: OperationResponseMap requestHeaders?: RequestHeader[] errors?: OperationErrorGroup security?: SecurityRequirement[] diff --git a/packages/zenko/src/utils/__tests__/treaty-tree.test.ts b/packages/zenko/src/utils/__tests__/treaty-tree.test.ts new file mode 100644 index 0000000..07c0d2b --- /dev/null +++ b/packages/zenko/src/utils/__tests__/treaty-tree.test.ts @@ -0,0 +1,46 @@ +import { describe, test, expect } from "bun:test" +import { + buildTreatyRouteTree, + emitTreatyRouteTree, + pathTemplateToSegments, +} from "../treaty-tree" + +describe("treaty-tree", () => { + test("pathTemplateToSegments maps brace params", () => { + expect(pathTemplateToSegments("/board/{row}/{column}")).toEqual([ + "board", + ":row", + ":column", + ]) + }) + + test("buildTreatyRouteTree merges static and dynamic segments", () => { + const tree = buildTreatyRouteTree({ + getBoard: { method: "get", path: "/board" }, + getSquare: { method: "get", path: "/board/{row}/{column}" }, + putSquare: { method: "put", path: "/board/{row}/{column}" }, + }) + + expect(tree).toEqual({ + board: { + get: "getBoard", + ":row": { + ":column": { + get: "getSquare", + put: "putSquare", + }, + }, + }, + }) + }) + + test("emitTreatyRouteTree emits quoted :param keys", () => { + const tree = buildTreatyRouteTree({ + getSquare: { method: "get", path: "/board/{row}/{column}" }, + }) + const emitted = emitTreatyRouteTree(tree) + expect(emitted).toContain("board: {") + expect(emitted).toContain('":row": {') + expect(emitted).toContain("getSquare,") + }) +}) diff --git a/packages/zenko/src/utils/treaty-tree.ts b/packages/zenko/src/utils/treaty-tree.ts new file mode 100644 index 0000000..ebc475f --- /dev/null +++ b/packages/zenko/src/utils/treaty-tree.ts @@ -0,0 +1,126 @@ +import { formatPropertyName } from "./property-name" + +const HTTP_METHODS = new Set([ + "get", + "post", + "put", + "patch", + "delete", + "options", + "head", + "connect", + "trace", +]) + +export type OperationMeta = { + method: string + path: string +} + +/** Path template segments: `/{a}/{b}` → `[":a", ":b"]` */ +export function pathTemplateToSegments(path: string): string[] { + return path + .split("/") + .filter(Boolean) + .map((segment) => + segment.startsWith("{") && segment.endsWith("}") + ? `:${segment.slice(1, -1)}` + : segment + ) +} + +/** + * Nested record: static segments and `:param` keys; leaves are operation export names per HTTP method. + */ +export type TreatyRouteTree = Record + +/** + * Merges operations into a single route tree. Same path prefix shares one object (e.g. `board` has `get` and `:row`). + */ +export function buildTreatyRouteTree( + metadata: Record +): TreatyRouteTree { + const root: TreatyRouteTree = {} + + for (const [operationExport, meta] of Object.entries(metadata)) { + const segments = pathTemplateToSegments(meta.path) + const method = meta.method.toLowerCase() + if (!HTTP_METHODS.has(method)) { + throw new Error( + `Unsupported method ${meta.method} for ${operationExport}` + ) + } + insertOperation(root, segments, method, operationExport) + } + + return root +} + +function insertOperation( + tree: TreatyRouteTree, + segments: string[], + method: string, + operationExport: string +): void { + if (segments.length === 0) { + throw new Error(`Empty path for ${operationExport}`) + } + + let cursor = tree + for (let i = 0; i < segments.length; i += 1) { + const segment = segments[i]! + const isLast = i === segments.length - 1 + + if (isLast) { + const existing = cursor[segment] + const bucket: Record = + existing !== undefined && + typeof existing === "object" && + existing !== null && + !Array.isArray(existing) + ? { ...(existing as Record) } + : {} + if (bucket[method] !== undefined) { + throw new Error( + `Duplicate ${method} on ${segment} for ${operationExport} vs ${JSON.stringify(bucket[method])}` + ) + } + bucket[method] = operationExport + cursor[segment] = bucket + } else { + const next = cursor[segment] + if ( + next === undefined || + typeof next !== "object" || + next === null || + Array.isArray(next) + ) { + cursor[segment] = {} + } + cursor = cursor[segment] as TreatyRouteTree + } + } +} + +/** + * Emits a nested object literal for `treatyRoutes`; values are operation identifiers (no quotes). + */ +export function emitTreatyRouteTree(tree: TreatyRouteTree): string { + return emitTree(tree, 1) +} + +function emitTree(node: TreatyRouteTree, depth: number): string { + const pad = " ".repeat(depth) + const lines: string[] = [] + for (const [key, val] of Object.entries(node)) { + const prop = formatPropertyName(key) + if (typeof val === "string") { + lines.push(`${pad}${prop}: ${val},`) + } else if (val && typeof val === "object" && !Array.isArray(val)) { + lines.push(`${pad}${prop}: {`) + lines.push(emitTree(val as TreatyRouteTree, depth + 1)) + lines.push(`${pad}},`) + } + } + return lines.join("\n") +} diff --git a/packages/zenko/src/zenko.ts b/packages/zenko/src/zenko.ts index 7c3b754..9590454 100644 --- a/packages/zenko/src/zenko.ts +++ b/packages/zenko/src/zenko.ts @@ -465,6 +465,8 @@ export function generateWithMetadata( output.push("") } + generateOperationMetadata(output, operations) + const result: GenerateResult = { output: output.join("\n"), } @@ -729,6 +731,33 @@ function generateOperationTypes( } } +/** + * Emits `operationMetadata` for Treaty-style clients: method, path template, and per-status response type names. + */ +function generateOperationMetadata(buffer: string[], operations: Operation[]) { + buffer.push("// Operation Metadata") + buffer.push("export const operationMetadata = {") + + for (const op of operations) { + const camelCaseOperationId = toCamelCase(op.operationId) + buffer.push(` ${formatPropertyName(camelCaseOperationId)}: {`) + buffer.push(` method: ${JSON.stringify(op.method)},`) + buffer.push(` path: ${JSON.stringify(op.path)},`) + if (op.successResponses && Object.keys(op.successResponses).length > 0) { + buffer.push( + ` successResponses: ${JSON.stringify(op.successResponses)},` + ) + } + if (op.errorResponses && Object.keys(op.errorResponses).length > 0) { + buffer.push(` errorResponses: ${JSON.stringify(op.errorResponses)},`) + } + buffer.push(" },") + } + + buffer.push("} as const;") + buffer.push("") +} + function buildOperationErrorsType(errors?: OperationErrorGroup): string { if (!errors || !hasAnyErrors(errors)) { return "OperationErrors" diff --git a/packages/zenko/tsdown.config.ts b/packages/zenko/tsdown.config.ts index 88d2568..13ef13e 100644 --- a/packages/zenko/tsdown.config.ts +++ b/packages/zenko/tsdown.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ entry: { index: "index.ts", cli: "src/cli.ts", + treaty: "src/treaty.ts", }, // Output both CJS and ESM formats diff --git a/packages/zenko/zenko-config.schema.json b/packages/zenko/zenko-config.schema.json index a0db996..afaae5f 100644 --- a/packages/zenko/zenko-config.schema.json +++ b/packages/zenko/zenko-config.schema.json @@ -77,6 +77,10 @@ "type": "string", "description": "Path for generated TypeScript output file. Relative to config file directory." }, + "treatyOutput": { + "type": "string", + "description": "Optional path for a treaty-style client module generated from the main Zenko .gen.ts output. Relative to the config file directory." + }, "strictDates": { "type": "boolean", "description": "Enable strict ISO 8601 date/time format validation in Zod schemas. Overrides global setting."