zenko 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![CI](https://github.com/RawToast/zenko/actions/workflows/ci.yml/badge.svg)](https://github.com/RawToast/zenko/actions/workflows/ci.yml)
4
4
 
5
- A TypeScript generator for OpenAPI specifications that creates **Zod schemas**, **type-safe path functions**, and **operation objects**.
5
+ A work in progress TypeScript generator for OpenAPI specifications that creates **Zod schemas**, **type-safe path functions**, and **operation objects**.
6
6
 
7
7
  Unlike most OpenAPI generators, Zenko does not create a client. Instead you are free to use your own fetch wrapper or library of choice.
8
8
 
@@ -11,6 +11,7 @@ Unlike most OpenAPI generators, Zenko does not create a client. Instead you are
11
11
  - 🔧 **Zod Schema Generation** - Generates runtime-validated Zod schemas from OpenAPI schemas
12
12
  - 🛣️ **Type-safe Path Functions** - Creates functions to build API paths with proper TypeScript types
13
13
  - 📋 **Operation Objects** - Generates objects containing path functions, request validation, and response types
14
+ - 🧰 **Operation Type Helpers** - Import `PathFn`, `HeaderFn`, `OperationDefinition`, and `OperationErrors` to power reusable clients
14
15
  - 🔄 **Dependency Resolution** - Automatically resolves schema dependencies with topological sorting
15
16
  - ⚡ **CLI & Programmatic API** - Use via command line or import as a library
16
17
 
@@ -66,10 +67,14 @@ bunx zenko input.yaml output.ts
66
67
 
67
68
  ### Config File
68
69
 
69
- The config file is a JSON file that contains an array of schema objects. Each schema object contains the input and output files, and the strict dates and numeric options.
70
+ The config file controls generation for multiple specs and can also configure type helper emission.
70
71
 
71
72
  ```json
72
73
  {
74
+ "types": {
75
+ "emit": true,
76
+ "helpers": "package"
77
+ },
73
78
  "schemas": [
74
79
  {
75
80
  "input": "my-api.yaml",
@@ -79,12 +84,22 @@ The config file is a JSON file that contains an array of schema objects. Each sc
79
84
  "input": "my-strict-api.yaml",
80
85
  "output": "my-strict-api.gen.ts",
81
86
  "strictDates": true,
82
- "strictNumeric": true
87
+ "strictNumeric": true,
88
+ "types": {
89
+ "helpers": "inline"
90
+ }
83
91
  }
84
92
  ]
85
93
  }
86
94
  ```
87
95
 
96
+ #### Type Helper Modes
97
+
98
+ - `helpers: "package"` (default) imports helpers from `zenko`
99
+ - `helpers: "inline"` writes the helper definitions into each generated file
100
+ - `helpers: "file"` imports from a custom module (`helpersOutput` path)
101
+ - `emit: false` disables per-operation type aliases entirely
102
+
88
103
  ### Programmatic Usage
89
104
 
90
105
  ```typescript
@@ -157,7 +172,7 @@ export const paths = {
157
172
  } as const
158
173
  ```
159
174
 
160
- ### 3. Operation Objects
175
+ ### 3. Operation Objects & Types
161
176
 
162
177
  ```typescript
163
178
  // Operation Objects
@@ -171,12 +186,28 @@ export const getUserById = {
171
186
  path: paths.getUserById,
172
187
  response: UserResponse,
173
188
  } as const
189
+
190
+ // Operation Types
191
+ import type { OperationDefinition, OperationErrors } from "zenko"
192
+
193
+ export type AuthenticateUserOperation = OperationDefinition<
194
+ typeof paths.authenticateUser,
195
+ typeof AuthenticateRequest,
196
+ typeof AuthenticateResponse,
197
+ undefined,
198
+ OperationErrors
199
+ >
174
200
  ```
175
201
 
176
202
  ## Example Usage in Your App
177
203
 
178
204
  ```typescript
179
- import { paths, authenticateUser, AuthenticateRequest } from "./generated-types"
205
+ import {
206
+ paths,
207
+ authenticateUser,
208
+ type AuthenticateUserOperation,
209
+ AuthenticateRequest,
210
+ } from "./generated-types"
180
211
 
181
212
  // Type-safe path building
182
213
  const userPath = paths.getUserById({ userId: "123" })
@@ -201,6 +232,30 @@ if (validation.success) {
201
232
  }
202
233
  ```
203
234
 
235
+ ### Building a Generic Client
236
+
237
+ ```typescript
238
+ import type { OperationDefinition } from "zenko"
239
+
240
+ async function runOperation<
241
+ T extends OperationDefinition<PathFn<any[]>, any, any>,
242
+ >(
243
+ operation: T,
244
+ config: { baseUrl: string; init?: RequestInit }
245
+ ): Promise<
246
+ T["response"] extends undefined
247
+ ? void
248
+ : T["response"] extends (...args: any[]) => infer U
249
+ ? U
250
+ : T["response"]
251
+ > {
252
+ const url = `${config.baseUrl}${operation.path()}`
253
+ const res = await fetch(url, config.init)
254
+ if (!res.ok) throw new Error(`Request failed: ${res.status}`)
255
+ return (await res.json()) as any
256
+ }
257
+ ```
258
+
204
259
  ## Key Improvements
205
260
 
206
261
  ### Dependency Resolution
@@ -234,10 +289,3 @@ zenko src/resources/petstore.yaml output.ts
234
289
  # Format code
235
290
  bun run format
236
291
  ```
237
-
238
- ## Architecture
239
-
240
- - **`src/zenko.ts`** - Main OpenAPI → TypeScript generator
241
- - **`src/cli.ts`** - Command-line interface
242
- - **`dist/`** - Bundled outputs (CJS + ESM)
243
- - **Topological Sort** - Ensures proper dependency ordering for schema generation
package/dist/cli.cjs CHANGED
@@ -237,11 +237,13 @@ function generate(spec, options = {}) {
237
237
  const output = [];
238
238
  const generatedTypes = /* @__PURE__ */ new Set();
239
239
  const { strictDates = false, strictNumeric = false } = options;
240
+ const typesConfig = normalizeTypesConfig(options.types);
240
241
  const schemaOptions = {
241
242
  strictDates,
242
243
  strictNumeric
243
244
  };
244
245
  output.push('import { z } from "zod";');
246
+ appendHelperTypesImport(output, typesConfig);
245
247
  output.push("");
246
248
  if (spec.components?.schemas) {
247
249
  output.push("// Generated Zod Schemas");
@@ -261,16 +263,79 @@ function generate(spec, options = {}) {
261
263
  output.push("// Path Functions");
262
264
  output.push("export const paths = {");
263
265
  for (const op of operations) {
264
- if (op.pathParams.length === 0) {
266
+ const pathParamNames = op.pathParams.map((p) => p.name);
267
+ const hasPathParams = pathParamNames.length > 0;
268
+ const hasQueryParams = op.queryParams.length > 0;
269
+ if (!hasPathParams && !hasQueryParams) {
265
270
  output.push(` ${op.operationId}: () => "${op.path}",`);
266
- } else {
267
- const paramNames = op.pathParams.map((p) => p.name).join(", ");
268
- const paramTypes = op.pathParams.map((p) => `${p.name}: string`).join(", ");
269
- const pathWithParams = op.path.replace(/{([^}]+)}/g, "${$1}");
271
+ continue;
272
+ }
273
+ const allParamNames = [
274
+ ...pathParamNames,
275
+ ...op.queryParams.map((p) => p.name)
276
+ ];
277
+ const signaturePieces = [];
278
+ for (const param of op.pathParams) {
279
+ signaturePieces.push(`${param.name}: string`);
280
+ }
281
+ for (const param of op.queryParams) {
282
+ signaturePieces.push(
283
+ `${param.name}${param.required ? "" : "?"}: ${mapQueryType(param)}`
284
+ );
285
+ }
286
+ const signatureParams = signaturePieces.join(", ");
287
+ const needsDefaultObject = !hasPathParams && hasQueryParams && op.queryParams.every((param) => !param.required);
288
+ const signatureArgs = allParamNames.length ? `{ ${allParamNames.join(", ")} }` : "{}";
289
+ const signature = `${signatureArgs}: { ${signatureParams} }${needsDefaultObject ? " = {}" : ""}`;
290
+ const pathWithParams = op.path.replace(/{([^}]+)}/g, "${$1}");
291
+ if (!hasQueryParams) {
270
292
  output.push(
271
- ` ${op.operationId}: ({ ${paramNames} }: { ${paramTypes} }) => \`${pathWithParams}\`,`
293
+ ` ${op.operationId}: (${signature}) => \`${pathWithParams}\`,`
272
294
  );
295
+ continue;
296
+ }
297
+ output.push(` ${op.operationId}: (${signature}) => {`);
298
+ output.push(" const params = new URLSearchParams()");
299
+ for (const param of op.queryParams) {
300
+ const propertyKey = formatPropertyName(param.name);
301
+ const accessor = isValidJSIdentifier(param.name) ? param.name : propertyKey;
302
+ const schema = param.schema ?? {};
303
+ if (schema?.type === "array") {
304
+ const itemValueExpression = convertQueryParamValue(
305
+ schema.items ?? {},
306
+ "value"
307
+ );
308
+ if (param.required) {
309
+ output.push(` for (const value of ${accessor}) {`);
310
+ output.push(
311
+ ` params.append("${param.name}", ${itemValueExpression})`
312
+ );
313
+ output.push(" }");
314
+ } else {
315
+ output.push(` if (${accessor} !== undefined) {`);
316
+ output.push(` for (const value of ${accessor}) {`);
317
+ output.push(
318
+ ` params.append("${param.name}", ${itemValueExpression})`
319
+ );
320
+ output.push(" }");
321
+ output.push(" }");
322
+ }
323
+ continue;
324
+ }
325
+ const valueExpression = convertQueryParamValue(schema, accessor);
326
+ if (param.required) {
327
+ output.push(` params.set("${param.name}", ${valueExpression})`);
328
+ } else {
329
+ output.push(` if (${accessor} !== undefined) {`);
330
+ output.push(` params.set("${param.name}", ${valueExpression})`);
331
+ output.push(" }");
332
+ }
273
333
  }
334
+ output.push(" const _searchParams = params.toString()");
335
+ output.push(
336
+ ` return \`${pathWithParams}\${_searchParams ? \`?\${_searchParams}\` : ""}\``
337
+ );
338
+ output.push(" },");
274
339
  }
275
340
  output.push("} as const;");
276
341
  output.push("");
@@ -362,6 +427,7 @@ function generate(spec, options = {}) {
362
427
  output.push("} as const;");
363
428
  output.push("");
364
429
  }
430
+ generateOperationTypes(output, operations, typesConfig);
365
431
  return output.join("\n");
366
432
  }
367
433
  function appendOperationField(buffer, key, value) {
@@ -397,11 +463,13 @@ function parseOperations(spec) {
397
463
  );
398
464
  const resolvedParameters = collectParameters(pathItem, operation, spec);
399
465
  const requestHeaders = getRequestHeaders(resolvedParameters);
466
+ const queryParams = getQueryParams(resolvedParameters);
400
467
  operations.push({
401
468
  operationId: operation.operationId,
402
469
  path: path2,
403
470
  method: method.toLowerCase(),
404
471
  pathParams,
472
+ queryParams,
405
473
  requestType,
406
474
  responseType: successResponse,
407
475
  requestHeaders,
@@ -411,6 +479,91 @@ function parseOperations(spec) {
411
479
  }
412
480
  return operations;
413
481
  }
482
+ function normalizeTypesConfig(config) {
483
+ return {
484
+ emit: config?.emit ?? true,
485
+ helpers: config?.helpers ?? "package",
486
+ helpersOutput: config?.helpersOutput ?? "./zenko-types"
487
+ };
488
+ }
489
+ function appendHelperTypesImport(buffer, config) {
490
+ if (!config.emit) return;
491
+ switch (config.helpers) {
492
+ case "package":
493
+ buffer.push(
494
+ 'import type { PathFn, HeaderFn, OperationDefinition, OperationErrors } from "zenko";'
495
+ );
496
+ return;
497
+ case "file":
498
+ buffer.push(
499
+ `import type { PathFn, HeaderFn, OperationDefinition, OperationErrors } from "${config.helpersOutput}";`
500
+ );
501
+ return;
502
+ case "inline":
503
+ buffer.push(
504
+ "type PathFn<TArgs extends unknown[] = []> = (...args: TArgs) => string;"
505
+ );
506
+ buffer.push(
507
+ "type HeaderFn<TArgs extends unknown[] = [], TResult = Record<string, unknown> | Record<string, never>> = (...args: TArgs) => TResult;"
508
+ );
509
+ buffer.push(
510
+ "type OperationErrors<TClient = unknown, TServer = unknown, TDefault = unknown, TOther = unknown> = {"
511
+ );
512
+ buffer.push(" clientErrors?: Record<string, TClient>;");
513
+ buffer.push(" serverErrors?: Record<string, TServer>;");
514
+ buffer.push(" defaultErrors?: Record<string, TDefault>;");
515
+ buffer.push(" otherErrors?: Record<string, TOther>;");
516
+ buffer.push("};");
517
+ buffer.push(
518
+ "type OperationDefinition<TPath extends (...args: any[]) => string, TRequest = undefined, TResponse = undefined, THeaders extends HeaderFn | undefined = undefined, TErrors extends OperationErrors | undefined = undefined> = {"
519
+ );
520
+ buffer.push(" path: TPath;");
521
+ buffer.push(" request?: TRequest;");
522
+ buffer.push(" response?: TResponse;");
523
+ buffer.push(" headers?: THeaders;");
524
+ buffer.push(" errors?: TErrors;");
525
+ buffer.push("};");
526
+ return;
527
+ }
528
+ }
529
+ function generateOperationTypes(buffer, operations, config) {
530
+ if (!config.emit) return;
531
+ buffer.push("// Operation Types");
532
+ for (const op of operations) {
533
+ const headerType = op.requestHeaders?.length ? `typeof headers.${op.operationId}` : "undefined";
534
+ const requestType = op.requestType ?? "undefined";
535
+ const responseType = op.responseType ?? "undefined";
536
+ const errorsType = buildOperationErrorsType(op.errors);
537
+ buffer.push(
538
+ `export type ${capitalize(op.operationId)}Operation = OperationDefinition<`
539
+ );
540
+ buffer.push(` typeof paths.${op.operationId},`);
541
+ buffer.push(` ${requestType},`);
542
+ buffer.push(` ${responseType},`);
543
+ buffer.push(` ${headerType},`);
544
+ buffer.push(` ${errorsType}`);
545
+ buffer.push(`>;`);
546
+ buffer.push("");
547
+ }
548
+ }
549
+ function buildOperationErrorsType(errors) {
550
+ if (!errors || !hasAnyErrors(errors)) {
551
+ return "OperationErrors";
552
+ }
553
+ const client = buildErrorBucket(errors.clientErrors);
554
+ const server = buildErrorBucket(errors.serverErrors);
555
+ const fallback = buildErrorBucket(errors.defaultErrors);
556
+ const other = buildErrorBucket(errors.otherErrors);
557
+ return `OperationErrors<${client}, ${server}, ${fallback}, ${other}>`;
558
+ }
559
+ function buildErrorBucket(bucket) {
560
+ if (!bucket || Object.keys(bucket).length === 0) {
561
+ return "unknown";
562
+ }
563
+ const entries = Object.entries(bucket);
564
+ const typeEntries = entries.map(([name, type]) => `${formatPropertyName(name)}: ${type}`).join("; ");
565
+ return `{ ${typeEntries} }`;
566
+ }
414
567
  function collectParameters(pathItem, operation, spec) {
415
568
  const parametersMap = /* @__PURE__ */ new Map();
416
569
  const addParameters = (params) => {
@@ -553,6 +706,20 @@ function getRequestHeaders(parameters) {
553
706
  }
554
707
  return headers;
555
708
  }
709
+ function getQueryParams(parameters) {
710
+ const queryParams = [];
711
+ for (const param of parameters ?? []) {
712
+ if (param.in === "query") {
713
+ queryParams.push({
714
+ name: param.name,
715
+ description: param.description,
716
+ schema: param.schema,
717
+ required: param.required
718
+ });
719
+ }
720
+ }
721
+ return queryParams;
722
+ }
556
723
  function mapHeaderType(header) {
557
724
  const schemaType = header.schema?.type;
558
725
  switch (schemaType) {
@@ -565,6 +732,39 @@ function mapHeaderType(header) {
565
732
  return "string";
566
733
  }
567
734
  }
735
+ function mapQueryType(param) {
736
+ return mapQuerySchemaType(param.schema);
737
+ }
738
+ function mapQuerySchemaType(schema) {
739
+ if (!schema) return "string";
740
+ if (schema.type === "array") {
741
+ const itemType = mapQuerySchemaType(schema.items);
742
+ return `Array<${itemType}>`;
743
+ }
744
+ switch (schema.type) {
745
+ case "integer":
746
+ case "number":
747
+ return "number";
748
+ case "boolean":
749
+ return "boolean";
750
+ default:
751
+ return "string";
752
+ }
753
+ }
754
+ function convertQueryParamValue(schema, accessor) {
755
+ if (!schema) {
756
+ return `String(${accessor})`;
757
+ }
758
+ switch (schema.type) {
759
+ case "integer":
760
+ case "number":
761
+ return `String(${accessor})`;
762
+ case "boolean":
763
+ return `${accessor} ? "true" : "false"`;
764
+ default:
765
+ return `String(${accessor})`;
766
+ }
767
+ }
568
768
  function generateZodSchema(name, schema, generatedTypes, options) {
569
769
  if (generatedTypes.has(name)) return "";
570
770
  generatedTypes.add(name);
@@ -573,18 +773,7 @@ function generateZodSchema(name, schema, generatedTypes, options) {
573
773
  return `export const ${name} = z.enum([${enumValues}]);`;
574
774
  }
575
775
  if (schema.type === "object" || schema.properties) {
576
- const properties = [];
577
- for (const [propName, propSchema] of Object.entries(
578
- schema.properties || {}
579
- )) {
580
- const isRequired = schema.required?.includes(propName) ?? false;
581
- const zodType = getZodTypeFromSchema(propSchema, options);
582
- const finalType = isRequired ? zodType : `${zodType}.optional()`;
583
- properties.push(` ${formatPropertyName(propName)}: ${finalType},`);
584
- }
585
- return `export const ${name} = z.object({
586
- ${properties.join("\n")}
587
- });`;
776
+ return `export const ${name} = ${buildZodObject(schema, options)};`;
588
777
  }
589
778
  if (schema.type === "array") {
590
779
  const itemSchema = schema.items ?? { type: "unknown" };
@@ -607,6 +796,9 @@ function getZodTypeFromSchema(schema, options) {
607
796
  const enumValues = schema.enum.map((v) => `"${v}"`).join(", ");
608
797
  return `z.enum([${enumValues}])`;
609
798
  }
799
+ if (schema.type === "object" || schema.properties) {
800
+ return buildZodObject(schema, options);
801
+ }
610
802
  switch (schema.type) {
611
803
  case "string":
612
804
  return buildString(schema, options);
@@ -627,6 +819,23 @@ function getZodTypeFromSchema(schema, options) {
627
819
  return "z.unknown()";
628
820
  }
629
821
  }
822
+ function buildZodObject(schema, options) {
823
+ const properties = [];
824
+ for (const [propName, propSchema] of Object.entries(
825
+ schema.properties || {}
826
+ )) {
827
+ const isRequired = schema.required?.includes(propName) ?? false;
828
+ const zodType = getZodTypeFromSchema(propSchema, options);
829
+ const finalType = isRequired ? zodType : `${zodType}.optional()`;
830
+ properties.push(` ${formatPropertyName(propName)}: ${finalType},`);
831
+ }
832
+ if (properties.length === 0) {
833
+ return "z.object({})";
834
+ }
835
+ return `z.object({
836
+ ${properties.join("\n")}
837
+ })`;
838
+ }
630
839
  function buildString(schema, options) {
631
840
  if (options.strictDates) {
632
841
  switch (schema.format) {
@@ -817,7 +1026,7 @@ function printHelp() {
817
1026
  console.log("");
818
1027
  console.log("Config file format:");
819
1028
  console.log(
820
- ' {"schemas": [{ input, output, strictDates?, strictNumeric? }] }'
1029
+ ' {"types"?: { emit?, helpers?, helpersOutput? }, "schemas": [{ input, output, strictDates?, strictNumeric?, types? }] }'
821
1030
  );
822
1031
  }
823
1032
  async function runFromConfig(parsed) {
@@ -826,14 +1035,17 @@ async function runFromConfig(parsed) {
826
1035
  const config = await loadConfig(resolvedConfigPath);
827
1036
  validateConfig(config);
828
1037
  const baseDir = path.dirname(resolvedConfigPath);
1038
+ const baseTypesConfig = config.types;
829
1039
  for (const entry of config.schemas) {
830
1040
  const inputFile = resolvePath(entry.input, baseDir);
831
1041
  const outputFile = resolvePath(entry.output, baseDir);
1042
+ const typesConfig = resolveTypesConfig(baseTypesConfig, entry.types);
832
1043
  await generateSingle({
833
1044
  inputFile,
834
1045
  outputFile,
835
1046
  strictDates: entry.strictDates ?? parsed.strictDates,
836
- strictNumeric: entry.strictNumeric ?? parsed.strictNumeric
1047
+ strictNumeric: entry.strictNumeric ?? parsed.strictNumeric,
1048
+ typesConfig
837
1049
  });
838
1050
  }
839
1051
  }
@@ -870,12 +1082,23 @@ function validateConfig(config) {
870
1082
  function resolvePath(filePath, baseDir) {
871
1083
  return path.isAbsolute(filePath) ? filePath : path.join(baseDir, filePath);
872
1084
  }
1085
+ function resolveTypesConfig(baseConfig, entryConfig) {
1086
+ if (!baseConfig && !entryConfig) return void 0;
1087
+ return {
1088
+ ...baseConfig,
1089
+ ...entryConfig
1090
+ };
1091
+ }
873
1092
  async function generateSingle(options) {
874
- const { inputFile, outputFile, strictDates, strictNumeric } = options;
1093
+ const { inputFile, outputFile, strictDates, strictNumeric, typesConfig } = options;
875
1094
  const resolvedInput = path.resolve(inputFile);
876
1095
  const resolvedOutput = path.resolve(outputFile);
877
1096
  const spec = readSpec(resolvedInput);
878
- const output = generate(spec, { strictDates, strictNumeric });
1097
+ const output = generate(spec, {
1098
+ strictDates,
1099
+ strictNumeric,
1100
+ types: typesConfig
1101
+ });
879
1102
  fs.mkdirSync(path.dirname(resolvedOutput), { recursive: true });
880
1103
  fs.writeFileSync(resolvedOutput, output);
881
1104
  console.log(`\u2705 Generated TypeScript types in ${resolvedOutput}`);