zenko 0.1.0 → 0.1.2

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,129 @@ 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 = wrapTypeReference(op.requestType);
535
+ const responseType = wrapTypeReference(op.responseType);
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 accessibleEntries = entries.map(([name, type]) => {
565
+ const propertyKey = formatPropertyName(name);
566
+ const valueType = wrapErrorValueType(type);
567
+ return `${propertyKey}: ${valueType}`;
568
+ });
569
+ return `{ ${accessibleEntries.join("; ")} }`;
570
+ }
571
+ var TYPE_KEYWORDS = /* @__PURE__ */ new Set([
572
+ "any",
573
+ "unknown",
574
+ "never",
575
+ "void",
576
+ "null",
577
+ "undefined",
578
+ "string",
579
+ "number",
580
+ "boolean",
581
+ "bigint",
582
+ "symbol"
583
+ ]);
584
+ function wrapTypeReference(typeName) {
585
+ if (!typeName) return "undefined";
586
+ if (typeName === "undefined") return "undefined";
587
+ if (TYPE_KEYWORDS.has(typeName)) return typeName;
588
+ if (typeName.startsWith("typeof ")) return typeName;
589
+ const identifierPattern = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
590
+ if (identifierPattern.test(typeName)) {
591
+ return `typeof ${typeName}`;
592
+ }
593
+ return typeName;
594
+ }
595
+ function wrapErrorValueType(typeName) {
596
+ if (!typeName) return "unknown";
597
+ if (TYPE_KEYWORDS.has(typeName)) return typeName;
598
+ if (typeName.startsWith("typeof ")) return typeName;
599
+ const identifierPattern = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
600
+ if (identifierPattern.test(typeName)) {
601
+ return `typeof ${typeName}`;
602
+ }
603
+ return typeName;
604
+ }
414
605
  function collectParameters(pathItem, operation, spec) {
415
606
  const parametersMap = /* @__PURE__ */ new Map();
416
607
  const addParameters = (params) => {
@@ -553,6 +744,20 @@ function getRequestHeaders(parameters) {
553
744
  }
554
745
  return headers;
555
746
  }
747
+ function getQueryParams(parameters) {
748
+ const queryParams = [];
749
+ for (const param of parameters ?? []) {
750
+ if (param.in === "query") {
751
+ queryParams.push({
752
+ name: param.name,
753
+ description: param.description,
754
+ schema: param.schema,
755
+ required: param.required
756
+ });
757
+ }
758
+ }
759
+ return queryParams;
760
+ }
556
761
  function mapHeaderType(header) {
557
762
  const schemaType = header.schema?.type;
558
763
  switch (schemaType) {
@@ -565,6 +770,39 @@ function mapHeaderType(header) {
565
770
  return "string";
566
771
  }
567
772
  }
773
+ function mapQueryType(param) {
774
+ return mapQuerySchemaType(param.schema);
775
+ }
776
+ function mapQuerySchemaType(schema) {
777
+ if (!schema) return "string";
778
+ if (schema.type === "array") {
779
+ const itemType = mapQuerySchemaType(schema.items);
780
+ return `Array<${itemType}>`;
781
+ }
782
+ switch (schema.type) {
783
+ case "integer":
784
+ case "number":
785
+ return "number";
786
+ case "boolean":
787
+ return "boolean";
788
+ default:
789
+ return "string";
790
+ }
791
+ }
792
+ function convertQueryParamValue(schema, accessor) {
793
+ if (!schema) {
794
+ return `String(${accessor})`;
795
+ }
796
+ switch (schema.type) {
797
+ case "integer":
798
+ case "number":
799
+ return `String(${accessor})`;
800
+ case "boolean":
801
+ return `${accessor} ? "true" : "false"`;
802
+ default:
803
+ return `String(${accessor})`;
804
+ }
805
+ }
568
806
  function generateZodSchema(name, schema, generatedTypes, options) {
569
807
  if (generatedTypes.has(name)) return "";
570
808
  generatedTypes.add(name);
@@ -573,18 +811,7 @@ function generateZodSchema(name, schema, generatedTypes, options) {
573
811
  return `export const ${name} = z.enum([${enumValues}]);`;
574
812
  }
575
813
  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
- });`;
814
+ return `export const ${name} = ${buildZodObject(schema, options)};`;
588
815
  }
589
816
  if (schema.type === "array") {
590
817
  const itemSchema = schema.items ?? { type: "unknown" };
@@ -607,6 +834,9 @@ function getZodTypeFromSchema(schema, options) {
607
834
  const enumValues = schema.enum.map((v) => `"${v}"`).join(", ");
608
835
  return `z.enum([${enumValues}])`;
609
836
  }
837
+ if (schema.type === "object" || schema.properties) {
838
+ return buildZodObject(schema, options);
839
+ }
610
840
  switch (schema.type) {
611
841
  case "string":
612
842
  return buildString(schema, options);
@@ -627,6 +857,23 @@ function getZodTypeFromSchema(schema, options) {
627
857
  return "z.unknown()";
628
858
  }
629
859
  }
860
+ function buildZodObject(schema, options) {
861
+ const properties = [];
862
+ for (const [propName, propSchema] of Object.entries(
863
+ schema.properties || {}
864
+ )) {
865
+ const isRequired = schema.required?.includes(propName) ?? false;
866
+ const zodType = getZodTypeFromSchema(propSchema, options);
867
+ const finalType = isRequired ? zodType : `${zodType}.optional()`;
868
+ properties.push(` ${formatPropertyName(propName)}: ${finalType},`);
869
+ }
870
+ if (properties.length === 0) {
871
+ return "z.object({})";
872
+ }
873
+ return `z.object({
874
+ ${properties.join("\n")}
875
+ })`;
876
+ }
630
877
  function buildString(schema, options) {
631
878
  if (options.strictDates) {
632
879
  switch (schema.format) {
@@ -817,7 +1064,7 @@ function printHelp() {
817
1064
  console.log("");
818
1065
  console.log("Config file format:");
819
1066
  console.log(
820
- ' {"schemas": [{ input, output, strictDates?, strictNumeric? }] }'
1067
+ ' {"types"?: { emit?, helpers?, helpersOutput? }, "schemas": [{ input, output, strictDates?, strictNumeric?, types? }] }'
821
1068
  );
822
1069
  }
823
1070
  async function runFromConfig(parsed) {
@@ -826,14 +1073,17 @@ async function runFromConfig(parsed) {
826
1073
  const config = await loadConfig(resolvedConfigPath);
827
1074
  validateConfig(config);
828
1075
  const baseDir = path.dirname(resolvedConfigPath);
1076
+ const baseTypesConfig = config.types;
829
1077
  for (const entry of config.schemas) {
830
1078
  const inputFile = resolvePath(entry.input, baseDir);
831
1079
  const outputFile = resolvePath(entry.output, baseDir);
1080
+ const typesConfig = resolveTypesConfig(baseTypesConfig, entry.types);
832
1081
  await generateSingle({
833
1082
  inputFile,
834
1083
  outputFile,
835
1084
  strictDates: entry.strictDates ?? parsed.strictDates,
836
- strictNumeric: entry.strictNumeric ?? parsed.strictNumeric
1085
+ strictNumeric: entry.strictNumeric ?? parsed.strictNumeric,
1086
+ typesConfig
837
1087
  });
838
1088
  }
839
1089
  }
@@ -870,12 +1120,23 @@ function validateConfig(config) {
870
1120
  function resolvePath(filePath, baseDir) {
871
1121
  return path.isAbsolute(filePath) ? filePath : path.join(baseDir, filePath);
872
1122
  }
1123
+ function resolveTypesConfig(baseConfig, entryConfig) {
1124
+ if (!baseConfig && !entryConfig) return void 0;
1125
+ return {
1126
+ ...baseConfig,
1127
+ ...entryConfig
1128
+ };
1129
+ }
873
1130
  async function generateSingle(options) {
874
- const { inputFile, outputFile, strictDates, strictNumeric } = options;
1131
+ const { inputFile, outputFile, strictDates, strictNumeric, typesConfig } = options;
875
1132
  const resolvedInput = path.resolve(inputFile);
876
1133
  const resolvedOutput = path.resolve(outputFile);
877
1134
  const spec = readSpec(resolvedInput);
878
- const output = generate(spec, { strictDates, strictNumeric });
1135
+ const output = generate(spec, {
1136
+ strictDates,
1137
+ strictNumeric,
1138
+ types: typesConfig
1139
+ });
879
1140
  fs.mkdirSync(path.dirname(resolvedOutput), { recursive: true });
880
1141
  fs.writeFileSync(resolvedOutput, output);
881
1142
  console.log(`\u2705 Generated TypeScript types in ${resolvedOutput}`);