@kizunajs/kotlin 2.0.0-beta.2 → 2.0.0-beta.20

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
@@ -10,8 +10,25 @@ pnpm add -D @kizunajs/kotlin
10
10
 
11
11
  ## Usage
12
12
 
13
+ Name the client in your config, and `kizuna generate` writes it with the rest:
14
+
15
+ ```ts
16
+ import { kotlinClient } from '@kizunajs/kotlin';
17
+
18
+ export default defineConfig({
19
+ routes,
20
+ clients: [
21
+ kotlinClient({
22
+ output: '../android/app/src/main/kotlin/com/example/APIClient.kt',
23
+ namespace: 'API',
24
+ package: 'com.example',
25
+ }),
26
+ ],
27
+ });
28
+ ```
29
+
13
30
  ```sh
14
- kizuna-kotlin generate --config kizuna.config.ts --out android/app/src/main/kotlin/com/example/APIClient.kt --namespace-name API --package com.example
31
+ kizuna generate
15
32
  ```
16
33
 
17
34
  ## Documentation
package/dist/cli.cjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- const require_generator = require("./generator-s2pn7vqP.cjs");
2
+ const require_generator = require("./generator-Bg4HlRb3.cjs");
3
3
  let node_fs = require("node:fs");
4
4
  let node_path = require("node:path");
5
5
  let node_util = require("node:util");
package/dist/cli.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { t as generateKotlinClient } from "./generator-BvNpSarY.mjs";
2
+ import { t as generateKotlinClient } from "./generator-D4GvH1-a.mjs";
3
3
  import { mkdirSync, writeFileSync } from "node:fs";
4
4
  import { dirname, resolve } from "node:path";
5
5
  import { parseArgs } from "node:util";
@@ -56,6 +56,7 @@ var TypeRegistry = class {
56
56
  explicitIds = /* @__PURE__ */ new Set();
57
57
  sealedVariantPayloads = /* @__PURE__ */ new Set();
58
58
  sealedVariantPaths = /* @__PURE__ */ new Map();
59
+ brands = /* @__PURE__ */ new Map();
59
60
  usesJsonElement = false;
60
61
  constructor(camelCaseProperties = false, unknownEnumCase = false) {
61
62
  this.camelCaseProperties = camelCaseProperties;
@@ -99,6 +100,15 @@ var TypeRegistry = class {
99
100
  sealedVariantPath(name) {
100
101
  return this.sealedVariantPaths.get(name);
101
102
  }
103
+ addBrand(brand) {
104
+ const existing = this.brands.get(brand.name);
105
+ if (existing !== void 0 && existing.rawType !== brand.rawType) throw new Error(`@kizunajs/kotlin: brand ${stringify(brand.name)} wraps both ${existing.rawType} and ${brand.rawType}. Give each its own name.`);
106
+ if (this.types.has(brand.name)) throw new Error(`@kizunajs/kotlin: brand ${stringify(brand.name)} has the same name as a model. Give each its own name.`);
107
+ this.brands.set(brand.name, brand);
108
+ }
109
+ allBrands() {
110
+ return Array.from(this.brands.values());
111
+ }
102
112
  warnJsonElement(hint, reason) {
103
113
  this.usesJsonElement = true;
104
114
  this.warningSet.add(`${hint}: ${reason}`);
@@ -117,8 +127,7 @@ const propertyName = (key, camelCase) => {
117
127
  if (!camel) return "field";
118
128
  return /^[0-9]/.test(camel) ? `_${camel}` : camel;
119
129
  };
120
- const objectFields = (schema, registry, hint) => {
121
- const shape = (0, kizunajs_generator.readObjectShape)(schema) ?? {};
130
+ const objectFields = (schema, registry, hint, shape = (0, kizunajs_generator.readObjectShape)(schema) ?? {}) => {
122
131
  const fields = [];
123
132
  const seen = /* @__PURE__ */ new Map();
124
133
  for (const [key, value] of Object.entries(shape)) {
@@ -133,6 +142,7 @@ const objectFields = (schema, registry, hint) => {
133
142
  wireName: key,
134
143
  type: result.expression,
135
144
  optional: result.optional,
145
+ requiredNullable: result.requiredNullable,
136
146
  description: (0, kizunajs_generator.readMetaDescription)(value),
137
147
  isFile: result.isFile,
138
148
  deprecated: deprecation !== void 0,
@@ -142,12 +152,38 @@ const objectFields = (schema, registry, hint) => {
142
152
  return fields;
143
153
  };
144
154
  const stringify = (value) => JSON.stringify(value);
155
+ const BRANDABLE_TYPES = new Set([
156
+ "String",
157
+ "Int",
158
+ "Long",
159
+ "Double",
160
+ "Boolean",
161
+ "Instant"
162
+ ]);
145
163
  const mapType = (schema, registry, hint) => {
164
+ const result = mapUnbrandedType(schema, registry, hint);
165
+ const brand = (0, kizunajs_generator.readMetaBrand)(schema);
166
+ if (brand === void 0 || !BRANDABLE_TYPES.has(result.expression)) return result;
167
+ const name = (0, kizunajs_generator.sanitizeIdentifier)(brand);
168
+ registry.addBrand({
169
+ name,
170
+ rawType: result.expression
171
+ });
172
+ return {
173
+ expression: name,
174
+ optional: false
175
+ };
176
+ };
177
+ const mapUnbrandedType = (schema, registry, hint) => {
146
178
  if ((0, kizunajs_generator.isFileSchema)(schema)) return {
147
179
  expression: "MultipartFile",
148
180
  optional: false,
149
181
  isFile: true
150
182
  };
183
+ if ((0, kizunajs_generator.isBinarySchema)(schema)) return {
184
+ expression: "ByteArray",
185
+ optional: false
186
+ };
151
187
  const id = (0, kizunajs_generator.readMetaId)(schema);
152
188
  const def = (0, kizunajs_generator.readDef)(schema);
153
189
  const discriminated = (0, kizunajs_generator.readDiscriminatedUnion)(schema);
@@ -223,12 +259,13 @@ const mapType = (schema, registry, hint) => {
223
259
  optional: false
224
260
  };
225
261
  }
226
- const { inner, optional } = (0, kizunajs_generator.unwrapOptionalWrappers)(schema);
227
- if (optional) {
262
+ const { inner, optional, nullable } = (0, kizunajs_generator.unwrapOptionalWrappers)(schema);
263
+ if (optional || nullable) {
228
264
  const innerResult = mapType(inner, registry, hint);
229
265
  return {
230
266
  expression: innerResult.expression.endsWith("?") ? innerResult.expression : `${innerResult.expression}?`,
231
267
  optional: true,
268
+ requiredNullable: nullable && !optional,
232
269
  isFile: innerResult.isFile
233
270
  };
234
271
  }
@@ -277,8 +314,9 @@ const mapType = (schema, registry, hint) => {
277
314
  optional: false
278
315
  };
279
316
  }
317
+ const elementType = mapType(element, registry, `${hint}Item`).expression.replace(/\?$/, "");
280
318
  return {
281
- expression: `List<${mapType(element, registry, `${hint}Item`).expression.replace(/\?$/, "")}>`,
319
+ expression: `List<${(0, kizunajs_generator.unwrapOptionalWrappers)(element).nullable ? `${elementType}?` : elementType}>`,
282
320
  optional: false
283
321
  };
284
322
  }
@@ -412,6 +450,7 @@ const sanitizeCaseName = (literal) => {
412
450
  const collectObjectFields = (schema, registry, hint) => {
413
451
  return objectFields(schema, registry, hint);
414
452
  };
453
+ const hasDefault = (field) => field.optional && field.requiredNullable !== true;
415
454
  const objectFieldCount = (schema) => {
416
455
  return Object.keys((0, kizunajs_generator.readObjectShape)(schema) ?? {}).length;
417
456
  };
@@ -576,7 +615,7 @@ const buildRouteMethod = (routeKey, route, registry, deprecated, deprecationMess
576
615
  stream
577
616
  };
578
617
  };
579
- const kotlinGenerator = (0, kizunajs_generator.createGenerator)((options, _api) => {
618
+ const kotlinRoutesWalk = (options) => {
580
619
  const flatMethods = [];
581
620
  const groupMap = /* @__PURE__ */ new Map();
582
621
  return {
@@ -608,7 +647,7 @@ const kotlinGenerator = (0, kizunajs_generator.createGenerator)((options, _api)
608
647
  };
609
648
  }
610
649
  };
611
- });
650
+ };
612
651
  const KOTLIN_KEYWORDS = new Set([
613
652
  "as",
614
653
  "break",
@@ -642,6 +681,10 @@ const KOTLIN_KEYWORDS = new Set([
642
681
  const escapeKeyword = (name) => {
643
682
  return KOTLIN_KEYWORDS.has(name) ? `\`${name}\`` : name;
644
683
  };
684
+ const defaultArgument = (field) => {
685
+ if (!hasDefault(field)) return "";
686
+ return " = null";
687
+ };
645
688
  const optionalize = (type, optional) => {
646
689
  if (!optional) return type;
647
690
  return type.endsWith("?") ? type : `${type}?`;
@@ -653,8 +696,13 @@ const KOTLIN_PRIMITIVE_TYPES = new Set([
653
696
  "Long",
654
697
  "Boolean",
655
698
  "Instant",
656
- "Unit"
699
+ "Unit",
700
+ "ByteArray"
657
701
  ]);
702
+ /**
703
+ * How a response body is read. Raw bytes are the body already.
704
+ */
705
+ const decodeBody = (resolved) => resolved === "ByteArray" ? "data" : `json.decodeFromString<${resolved}>(data.decodeToString())`;
658
706
  const resolveType = (typeName, currentOperation, context, scope = "client") => {
659
707
  const { operationTypeMap, namespaceName, clientName, fileLevelTypeNames } = context;
660
708
  const optional = typeName.endsWith("?");
@@ -797,7 +845,7 @@ const emitDataClass = (writer, type, ownedTypeMap, ownedTypeLookup, registryName
797
845
  if (!hasFile) writer.line("@Serializable");
798
846
  const params = adjustedFields.map((field) => {
799
847
  const typeExpression = optionalize(field.type, field.optional);
800
- const defaultPart = field.optional ? " = null" : "";
848
+ const defaultPart = defaultArgument(field);
801
849
  const serialNameAnnotation = needsSerialName && (field.name !== field.wireName || KOTLIN_KEYWORDS.has(field.name)) ? `@SerialName(${stringLiteral(field.wireName)}) ` : "";
802
850
  return `${field.deprecated ? `${deprecatedAnnotation(field.deprecationMessage)} ` : ""}${serialNameAnnotation}val ${escapeKeyword(field.name)}: ${typeExpression}${defaultPart}`;
803
851
  });
@@ -890,7 +938,7 @@ const emitSealedClass = (writer, type, registry, ownedTypeMap, ownedTypeLookup)
890
938
  const params = fields.map((field) => {
891
939
  const variantPath = registry.sealedVariantPath(variant.payloadType) ?? variant.payloadType;
892
940
  const typeExpression = optionalize(relativeOwnedPath(field.type, ownedTypeMap, variantPath, registry) ?? ownedTypePath(field.type, ownedTypeMap, registry), field.optional);
893
- const defaultPart = field.optional ? " = null" : "";
941
+ const defaultPart = defaultArgument(field);
894
942
  return `val ${escapeKeyword(field.name)}: ${typeExpression}${defaultPart}`;
895
943
  });
896
944
  if (params.length === 1) writer.line(`data class ${variant.caseName}(${params[0]}) : ${type.name}`);
@@ -927,6 +975,16 @@ const emitType = (writer, type, ownedTypeMap = /* @__PURE__ */ new Map(), ownedT
927
975
  else if (type.kind === "enum-class") emitEnumClass(writer, type.name, type.cases, type.unknownCase, type.description);
928
976
  else if (registry) emitSealedClass(writer, type, registry, ownedTypeMap, ownedTypeLookup);
929
977
  };
978
+ /**
979
+ * A brand as a value class that serializes and prints as the bare value.
980
+ */
981
+ const emitBrand = (writer, brand) => {
982
+ writer.line("@Serializable");
983
+ writer.line("@JvmInline");
984
+ writer.block(`value class ${brand.name}(val value: ${brand.rawType})`, () => {
985
+ writer.line(`override fun toString(): String = ${brand.rawType === "String" ? "value" : "value.toString()"}`);
986
+ });
987
+ };
930
988
  const emitTypes = (writer, types, ownedTypeMap = /* @__PURE__ */ new Map(), ownedTypeLookup = /* @__PURE__ */ new Map(), registry) => {
931
989
  for (const type of types) emitType(writer, type, ownedTypeMap, ownedTypeLookup, void 0, registry);
932
990
  };
@@ -1002,7 +1060,7 @@ const emitOperationResultTypes = (writer, method, context) => {
1002
1060
  writer.line(") {");
1003
1061
  writer.indent(() => {
1004
1062
  writer.line("/** How far along the call is. */");
1005
- writer.line("enum class State { Running, Done, Failed }");
1063
+ writer.line("enum class State { Running, NeedsApproval, Done, Failed }");
1006
1064
  });
1007
1065
  writer.line("}");
1008
1066
  writer.blank();
@@ -1012,7 +1070,12 @@ const emitOperationResultTypes = (writer, method, context) => {
1012
1070
  writer.block("for (event in events)", () => {
1013
1071
  writer.line("when (event) {");
1014
1072
  writer.indent(() => {
1015
- writer.line("is Event.ToolCall -> calls[event.data.id] = (calls[event.data.id] ?: ToolCallRecord(event.data.id, event.data.name)).copy(call = event.data)");
1073
+ writer.line("is Event.ToolCall -> {");
1074
+ writer.indent(() => {
1075
+ writer.line("val tracked = (calls[event.data.id] ?: ToolCallRecord(event.data.id, event.data.name)).copy(call = event.data)");
1076
+ writer.line("calls[event.data.id] = if (event.data.needsApproval == true && tracked.state == ToolCallRecord.State.Running) tracked.copy(state = ToolCallRecord.State.NeedsApproval) else tracked");
1077
+ });
1078
+ writer.line("}");
1016
1079
  writer.line("is Event.ToolResult -> calls[event.data.id] = (calls[event.data.id] ?: ToolCallRecord(event.data.id, event.data.name)).copy(state = ToolCallRecord.State.Done, result = event.data)");
1017
1080
  if (eventNames.has("tool_error")) writer.line("is Event.ToolError -> calls[event.data.id] = (calls[event.data.id] ?: ToolCallRecord(event.data.id, event.data.name.wireValue)).copy(state = ToolCallRecord.State.Failed, message = event.data.message)");
1018
1081
  writer.line("else -> Unit");
@@ -1074,7 +1137,7 @@ const groupTypeRef = (operationName, className, context) => `${context.clientNam
1074
1137
  const groupMemberAccessor = (groupLabel, field) => `${groupLabel}.${escapeKeyword(field.name)}`;
1075
1138
  const groupClassField = (field) => {
1076
1139
  const typeExpression = optionalize(field.type, field.optional);
1077
- const defaultPart = field.optional ? " = null" : "";
1140
+ const defaultPart = defaultArgument(field);
1078
1141
  return `val ${escapeKeyword(field.name)}: ${typeExpression}${defaultPart}`;
1079
1142
  };
1080
1143
  const emitGroupClass = (writer, className, fields, method, context) => {
@@ -1133,13 +1196,13 @@ const methodChannels = (method) => {
1133
1196
  if (method.query.length > 0) channels.push({
1134
1197
  varName: "query",
1135
1198
  className: "Query",
1136
- required: method.query.some((field) => !field.optional),
1199
+ required: method.query.some((field) => !hasDefault(field)),
1137
1200
  fields: method.query
1138
1201
  });
1139
1202
  if (method.headers.length > 0) channels.push({
1140
1203
  varName: "headers",
1141
1204
  className: "Headers",
1142
- required: method.headers.some((field) => !field.optional),
1205
+ required: method.headers.some((field) => !hasDefault(field)),
1143
1206
  fields: method.headers
1144
1207
  });
1145
1208
  if (hasBodyGroup(method)) {
@@ -1188,7 +1251,7 @@ const emitArgsScaffolding = (writer, method, context) => {
1188
1251
  };
1189
1252
  const factorySignature = (channel) => channel.fields.map((field) => {
1190
1253
  const typeExpression = optionalize(field.type, field.optional);
1191
- const defaultPart = field.optional ? " = null" : "";
1254
+ const defaultPart = defaultArgument(field);
1192
1255
  return `${escapeKeyword(field.name)}: ${typeExpression}${defaultPart}`;
1193
1256
  }).join(", ");
1194
1257
  const factoryConstruction = (channel) => {
@@ -1245,10 +1308,16 @@ const emitBodyEncoding = (writer, method, context) => {
1245
1308
  if (body.kind === "multipart") {
1246
1309
  writer.line("val multipartBuilder = MultipartBody.Builder().setType(MultipartBody.FORM)");
1247
1310
  for (const field of body.multipartFields) {
1248
- const accessor = groupMemberAccessor("body", field);
1249
- const isFlattenedFile = body.flattened.find((flatField) => flatField.name === field.name)?.isFile === true;
1250
- if (field.isFile || isFlattenedFile) writer.line(`multipartBuilder.addFormDataPart(${stringLiteral(field.wireName)}, ${accessor}.filename, ${accessor}.data.toRequestBody(${accessor}.mimeType.toMediaType()))`);
1251
- else writer.line(`multipartBuilder.addFormDataPart(${stringLiteral(field.wireName)}, ${accessor}.toString())`);
1311
+ const flattenedField = body.flattened.find((flatField) => flatField.name === field.name);
1312
+ const isFile = field.isFile || flattenedField?.isFile === true;
1313
+ const append = (accessor) => {
1314
+ if (isFile) writer.line(`multipartBuilder.addFormDataPart(${stringLiteral(field.wireName)}, ${accessor}.filename, ${accessor}.data.toRequestBody(${accessor}.mimeType.toMediaType()))`);
1315
+ else writer.line(`multipartBuilder.addFormDataPart(${stringLiteral(field.wireName)}, ${accessor}.toString())`);
1316
+ };
1317
+ if (flattenedField?.optional === true) writer.block(`if (${groupMemberAccessor("body", field)} != null)`, () => {
1318
+ append(groupMemberAccessor("body", field));
1319
+ });
1320
+ else append(groupMemberAccessor("body", field));
1252
1321
  }
1253
1322
  writer.line("requestBody = multipartBuilder.build()");
1254
1323
  return;
@@ -1324,7 +1393,7 @@ const emitMethodBody = (writer, method, context) => {
1324
1393
  else setHeader(accessor);
1325
1394
  }
1326
1395
  writer.line("requestInterceptor?.invoke(requestBuilder)");
1327
- writer.line("val httpResponse = Kizuna.execute(client, requestBuilder.build())");
1396
+ writer.line("val httpResponse = Kizuna.send(client, requestBuilder.build(), retry)");
1328
1397
  if (method.stream) {
1329
1398
  emitStreamMethodTail(writer, method, context);
1330
1399
  return;
@@ -1344,7 +1413,7 @@ const emitMethodBody = (writer, method, context) => {
1344
1413
  writer.indent(() => {
1345
1414
  const resolved = resolveType(successResponse.type, method.operationName, context);
1346
1415
  writer.block("try", () => {
1347
- writer.line(`val payload = json.decodeFromString<${resolved}>(data.decodeToString())`);
1416
+ writer.line(`val payload = ${decodeBody(resolved)}`);
1348
1417
  const bodyExpr = isMultiSuccess ? `${operationRef}.Success.Status${successResponse.status}(payload)` : "payload";
1349
1418
  if (hasHeaders) {
1350
1419
  for (const field of successResponse.responseHeaders) writer.line(`val ${escapeKeyword(field.name)} = httpResponse.header(${stringLiteral(field.wireName)})`);
@@ -1381,7 +1450,7 @@ const emitErrorBranches = (writer, method, context) => {
1381
1450
  const resolved = resolveType(errorCase.type, method.operationName, context);
1382
1451
  writer.line("val payload = try {");
1383
1452
  writer.indent(() => {
1384
- writer.line(`json.decodeFromString<${resolved}>(data.decodeToString())`);
1453
+ writer.line(decodeBody(resolved));
1385
1454
  });
1386
1455
  writer.line(`} catch (error: Exception) { throw ${failureRef}.Decoding(error, statusCode, data) }`);
1387
1456
  writer.line(`throw ${failureRef}.${caseName}(body = payload)`);
@@ -1394,7 +1463,7 @@ const emitErrorBranches = (writer, method, context) => {
1394
1463
  const resolved = resolveType(errorCase.type, method.operationName, context);
1395
1464
  writer.line(`val ${errorCase.caseName} = try {`);
1396
1465
  writer.indent(() => {
1397
- writer.line(`json.decodeFromString<${resolved}>(data.decodeToString())`);
1466
+ writer.line(decodeBody(resolved));
1398
1467
  });
1399
1468
  writer.line("} catch (_: Exception) { null }");
1400
1469
  writer.line(`if (${errorCase.caseName} != null) throw ${failureRef}.${caseName}(body = ${errorCase.caseName})`);
@@ -1476,7 +1545,7 @@ const emitSubClientMethod = (writer, method, context) => {
1476
1545
  };
1477
1546
  const emitSubClientClass = (writer, group, clientName, context) => {
1478
1547
  writer.blank();
1479
- writer.block(`class ${group.className}(private val client: OkHttpClient, private val baseUrl: String, private val json: Json, private val requestContextHeaders: Map<String, String>, private val requestInterceptor: (suspend (Request.Builder) -> Unit)?, private val responseInterceptor: (suspend (Request, Response) -> Unit)?)`, () => {
1548
+ writer.block(`class ${group.className}(private val client: OkHttpClient, private val baseUrl: String, private val json: Json, private val requestContextHeaders: Map<String, String>, private val requestInterceptor: (suspend (Request.Builder) -> Unit)?, private val responseInterceptor: (suspend (Request, Response) -> Unit)?, private val retry: ${clientName}.RetryPolicy?)`, () => {
1480
1549
  for (const method of group.methods) {
1481
1550
  writer.blank();
1482
1551
  emitSubClientMethod(writer, method, context);
@@ -1581,6 +1650,47 @@ const emitKizunaObject = (writer, options) => {
1581
1650
  writer.line("}");
1582
1651
  });
1583
1652
  writer.blank();
1653
+ writer.block(`suspend fun send(client: OkHttpClient, request: Request, retry: ${options.clientName}.RetryPolicy?): Response`, () => {
1654
+ writer.line("val idempotent = request.method in setOf(\"GET\", \"HEAD\", \"OPTIONS\", \"PUT\", \"DELETE\")");
1655
+ writer.line("val policy = retry?.takeIf { idempotent && request.body?.isOneShot() != true }");
1656
+ writer.line("var attempt = 1");
1657
+ writer.block("while (true)", () => {
1658
+ writer.line("val response = try {");
1659
+ writer.line(" execute(client, request)");
1660
+ writer.line("} catch (error: java.io.IOException) {");
1661
+ writer.line(" val delay = policy?.let { retryDelay(it, attempt, null) } ?: throw error");
1662
+ writer.line(" kotlinx.coroutines.delay(delay)");
1663
+ writer.line(" attempt += 1");
1664
+ writer.line(" continue");
1665
+ writer.line("}");
1666
+ writer.line("val delay = policy?.let { retryDelay(it, attempt, response) } ?: return response");
1667
+ writer.line("response.close()");
1668
+ writer.line("kotlinx.coroutines.delay(delay)");
1669
+ writer.line("attempt += 1");
1670
+ });
1671
+ });
1672
+ writer.blank();
1673
+ writer.block(`fun retryDelay(policy: ${options.clientName}.RetryPolicy, attempt: Int, response: Response?): Long?`, () => {
1674
+ writer.line("if (attempt >= policy.maximumAttempts) return null");
1675
+ writer.line("val ceiling = minOf(policy.maximumDelay.inWholeMilliseconds, policy.initialDelay.inWholeMilliseconds shl (attempt - 1))");
1676
+ writer.line("val backoff = (Math.random() * ceiling).toLong()");
1677
+ writer.line("if (response == null) return backoff");
1678
+ writer.line("if (response.code != 429 && response.code != 503) return null");
1679
+ writer.line("val asked = retryAfter(response.header(\"Retry-After\")) ?: return backoff");
1680
+ writer.line("return if (asked <= policy.maximumDelay.inWholeMilliseconds) asked else null");
1681
+ });
1682
+ writer.blank();
1683
+ writer.block("fun retryAfter(header: String?): Long?", () => {
1684
+ writer.line("val value = header?.trim() ?: return null");
1685
+ writer.line("value.toLongOrNull()?.let { return if (it >= 0) it * 1000 else null }");
1686
+ writer.line("val date = try {");
1687
+ writer.line(" java.time.ZonedDateTime.parse(value, java.time.format.DateTimeFormatter.RFC_1123_DATE_TIME)");
1688
+ writer.line("} catch (_: java.time.format.DateTimeParseException) {");
1689
+ writer.line(" return null");
1690
+ writer.line("}");
1691
+ writer.line("return maxOf(0L, date.toInstant().toEpochMilli() - System.currentTimeMillis())");
1692
+ });
1693
+ writer.blank();
1584
1694
  writer.block("suspend fun execute(client: OkHttpClient, request: Request): Response", () => {
1585
1695
  writer.line("return kotlinx.coroutines.suspendCancellableCoroutine { continuation ->");
1586
1696
  writer.indent(() => {
@@ -1608,20 +1718,34 @@ const emitClient = (writer, config, partition, context, typesByOperation, regist
1608
1718
  const allMethods = [...flatMethods, ...groups.flatMap((group) => group.methods)];
1609
1719
  const usesMultipart = allMethods.some((method) => method.body?.kind === "multipart");
1610
1720
  const contextFields = context.requestContextFields;
1611
- const contextRequired = contextFields.some((field) => !field.optional);
1721
+ const contextRequired = contextFields.some((field) => !hasDefault(field));
1612
1722
  const contextParam = contextFields.length > 0 ? `requestContext: RequestContext${contextRequired ? "" : " = RequestContext()"}, ` : "";
1613
1723
  writer.blank();
1614
- writer.block(`class ${clientName}(private val baseUrl: String, ${contextParam}private val client: OkHttpClient = OkHttpClient(), private val json: Json = Json { ignoreUnknownKeys = true }, private val requestInterceptor: (suspend (Request.Builder) -> Unit)? = null, private val responseInterceptor: (suspend (Request, Response) -> Unit)? = null)`, () => {
1724
+ writer.block(`class ${clientName}(private val baseUrl: String, ${contextParam}private val client: OkHttpClient = OkHttpClient(), private val json: Json = Json { ignoreUnknownKeys = true }, private val requestInterceptor: (suspend (Request.Builder) -> Unit)? = null, private val responseInterceptor: (suspend (Request, Response) -> Unit)? = null, private val retry: RetryPolicy? = null)`, () => {
1615
1725
  if (contextFields.length > 0) {
1616
1726
  writer.blank();
1617
1727
  writer.line("/** Values sent as headers on every request, from the api's request context. */");
1618
- emitConstructorClass(writer, "data class RequestContext", contextFields.map((field) => `val ${escapeKeyword(field.name)}: ${field.type}${field.optional ? " = null" : ""}`));
1728
+ emitConstructorClass(writer, "data class RequestContext", contextFields.map((field) => `val ${escapeKeyword(field.name)}: ${field.type}${defaultArgument(field)}`));
1619
1729
  writer.blank();
1620
1730
  writer.block("private val requestContextHeaders: Map<String, String> = buildMap", () => {
1621
1731
  for (const field of contextFields) if (field.optional) writer.line(`if (requestContext.${escapeKeyword(field.name)} != null) put(${stringLiteral(field.wireName)}, requestContext.${escapeKeyword(field.name)}!!)`);
1622
1732
  else writer.line(`put(${stringLiteral(field.wireName)}, requestContext.${escapeKeyword(field.name)})`);
1623
1733
  });
1624
1734
  }
1735
+ writer.blank();
1736
+ writer.line("/**");
1737
+ writer.line(" * Retries a GET, HEAD, OPTIONS, PUT or DELETE that failed on the network or was answered 429 or 503,");
1738
+ writer.line(" * waiting what Retry-After asks or backing off.");
1739
+ writer.line(" *");
1740
+ writer.line(" * @property maximumAttempts Attempts in all, the first included.");
1741
+ writer.line(" * @property initialDelay What the first backoff waits at most.");
1742
+ writer.line(" * @property maximumDelay What any wait may take.");
1743
+ writer.line(" */");
1744
+ emitConstructorClass(writer, "data class RetryPolicy", [
1745
+ "val maximumAttempts: Int = 3",
1746
+ "val initialDelay: kotlin.time.Duration = 500.milliseconds",
1747
+ "val maximumDelay: kotlin.time.Duration = 30.seconds"
1748
+ ]);
1625
1749
  if (usesMultipart) {
1626
1750
  writer.blank();
1627
1751
  emitConstructorClass(writer, "data class MultipartFile", [
@@ -1677,7 +1801,7 @@ const emitClient = (writer, config, partition, context, typesByOperation, regist
1677
1801
  }
1678
1802
  if (groups.length > 0) for (const group of groups) {
1679
1803
  writer.blank();
1680
- writer.line(`val ${escapeKeyword(group.propertyName)} = ${group.className}(client, baseUrl, json, ${contextFields.length > 0 ? "requestContextHeaders" : "emptyMap()"}, requestInterceptor, responseInterceptor)`);
1804
+ writer.line(`val ${escapeKeyword(group.propertyName)} = ${group.className}(client, baseUrl, json, ${contextFields.length > 0 ? "requestContextHeaders" : "emptyMap()"}, requestInterceptor, responseInterceptor, retry)`);
1681
1805
  }
1682
1806
  for (const method of flatMethods) {
1683
1807
  writer.blank();
@@ -1685,21 +1809,8 @@ const emitClient = (writer, config, partition, context, typesByOperation, regist
1685
1809
  }
1686
1810
  });
1687
1811
  };
1688
- /**
1689
- * Generate a Kotlin API client from a Kizuna api.
1690
- *
1691
- * @param api - What `defineConfig` assembled.
1692
- * @param config - Override the generated names:
1693
- * - `namespaceName`: the object wrapping shared types.
1694
- * - `packageName`: optional package declaration for the generated file.
1695
- */
1696
- const generateKotlinClient = (api, config) => {
1697
- const { namespaceName, packageName, camelCaseProperties = false, unknownEnumCase = false } = config;
1698
- const registry = new TypeRegistry(camelCaseProperties, unknownEnumCase);
1699
- const partition = kotlinGenerator(api, {
1700
- namespaceName,
1701
- registry
1702
- });
1812
+ const renderKotlinClient = (api, partition, registry, config) => {
1813
+ const { namespaceName, packageName, camelCaseProperties = false } = config;
1703
1814
  const allMethods = [...partition.flatMethods, ...partition.groups.flatMap((group) => group.methods)];
1704
1815
  const operationTypeMap = buildOperationTypeMap(allMethods, registry);
1705
1816
  const clientName = `${namespaceName}Client`;
@@ -1735,6 +1846,8 @@ const generateKotlinClient = (api, config) => {
1735
1846
  writer.line("import okhttp3.*");
1736
1847
  writer.line("import okhttp3.MediaType.Companion.toMediaType");
1737
1848
  writer.line("import okhttp3.RequestBody.Companion.toRequestBody");
1849
+ writer.line("import kotlin.time.Duration.Companion.milliseconds");
1850
+ writer.line("import kotlin.time.Duration.Companion.seconds");
1738
1851
  writer.line("import okhttp3.HttpUrl.Companion.toHttpUrl");
1739
1852
  writer.blank();
1740
1853
  if (registry.all().some((type) => type.kind === "enum-class")) {
@@ -1784,13 +1897,43 @@ const generateKotlinClient = (api, config) => {
1784
1897
  };
1785
1898
  writer.block(`object ${namespaceName}`, () => {
1786
1899
  emitTypes(writer, topLevelSharedTypes, ownedTypeMap, ownedTypeLookup, registry);
1900
+ for (const brand of registry.allBrands()) {
1901
+ writer.blank();
1902
+ emitBrand(writer, brand);
1903
+ }
1787
1904
  });
1788
1905
  emitClient(writer, { clientName }, partition, context, typesByOperation, registry);
1789
1906
  for (const group of partition.groups) emitSubClientClass(writer, group, clientName, context);
1790
- emitKizunaObject(writer, { streaming: usesStreaming });
1907
+ emitKizunaObject(writer, {
1908
+ streaming: usesStreaming,
1909
+ clientName
1910
+ });
1791
1911
  for (const warning of registry.warnings()) process.stderr.write(`[kizuna/kotlin] JsonElement fallback at ${warning}\n`);
1792
1912
  return writer.toString();
1793
1913
  };
1914
+ /**
1915
+ * The Kotlin client's walk: the routes into a partition, then the file.
1916
+ */
1917
+ const kotlinClientWalk = (api, config) => {
1918
+ const registry = new TypeRegistry(config.camelCaseProperties ?? false, config.unknownEnumCase ?? false);
1919
+ const routes = kotlinRoutesWalk({
1920
+ namespaceName: config.namespaceName,
1921
+ registry
1922
+ });
1923
+ return {
1924
+ processRoute: routes.processRoute,
1925
+ finalize: () => renderKotlinClient(api, routes.finalize(), registry, config)
1926
+ };
1927
+ };
1928
+ /**
1929
+ * Generate a Kotlin API client from a Kizuna api.
1930
+ *
1931
+ * @param api - What `defineConfig` assembled.
1932
+ * @param config - Override the generated names:
1933
+ * - `namespaceName`: the object wrapping shared types.
1934
+ * - `packageName`: optional package declaration for the generated file.
1935
+ */
1936
+ const generateKotlinClient = (api, config) => (0, kizunajs_generator.walkApi)(api, kotlinClientWalk(api, config), { skipHidden: true });
1794
1937
  //#endregion
1795
1938
  Object.defineProperty(exports, "generateKotlinClient", {
1796
1939
  enumerable: true,
@@ -1798,3 +1941,9 @@ Object.defineProperty(exports, "generateKotlinClient", {
1798
1941
  return generateKotlinClient;
1799
1942
  }
1800
1943
  });
1944
+ Object.defineProperty(exports, "kotlinClientWalk", {
1945
+ enumerable: true,
1946
+ get: function() {
1947
+ return kotlinClientWalk;
1948
+ }
1949
+ });
@@ -1,4 +1,4 @@
1
- import { createGenerator, isDiscriminatedUnionSchema, isFileSchema, isHintPrefix, isIntegerSchema, isNamedStream, isObjectSchema, isStreamResponse, isSuccessStatus, localTypeName, mergeHeaderFields, parsePath, readDef, readDefType, readDeprecation, readDiscriminatedUnion, readDiscriminatorStringLiteral, readMetaDescription, readMetaId, readObjectShape, resolveResponseBody, resolveResponseHeaders, routeStreams, sanitizeFieldName, sanitizeIdentifier, shortTypeName, soleStreamResponse, statusToCamelCase, streamMode, toCamelCase, toPascalCase, unwrapOptionalWrappers } from "kizunajs/generator";
1
+ import { isBinarySchema, isDiscriminatedUnionSchema, isFileSchema, isHintPrefix, isIntegerSchema, isNamedStream, isObjectSchema, isStreamResponse, isSuccessStatus, localTypeName, mergeHeaderFields, parsePath, readDef, readDefType, readDeprecation, readDiscriminatedUnion, readDiscriminatorStringLiteral, readMetaBrand, readMetaDescription, readMetaId, readObjectShape, resolveResponseBody, resolveResponseHeaders, routeStreams, sanitizeFieldName, sanitizeIdentifier, shortTypeName, soleStreamResponse, statusToCamelCase, streamMode, toCamelCase, toPascalCase, unwrapOptionalWrappers, walkApi } from "kizunajs/generator";
2
2
  //#region src/emit.ts
3
3
  var KotlinWriter = class {
4
4
  lines = [];
@@ -56,6 +56,7 @@ var TypeRegistry = class {
56
56
  explicitIds = /* @__PURE__ */ new Set();
57
57
  sealedVariantPayloads = /* @__PURE__ */ new Set();
58
58
  sealedVariantPaths = /* @__PURE__ */ new Map();
59
+ brands = /* @__PURE__ */ new Map();
59
60
  usesJsonElement = false;
60
61
  constructor(camelCaseProperties = false, unknownEnumCase = false) {
61
62
  this.camelCaseProperties = camelCaseProperties;
@@ -99,6 +100,15 @@ var TypeRegistry = class {
99
100
  sealedVariantPath(name) {
100
101
  return this.sealedVariantPaths.get(name);
101
102
  }
103
+ addBrand(brand) {
104
+ const existing = this.brands.get(brand.name);
105
+ if (existing !== void 0 && existing.rawType !== brand.rawType) throw new Error(`@kizunajs/kotlin: brand ${stringify(brand.name)} wraps both ${existing.rawType} and ${brand.rawType}. Give each its own name.`);
106
+ if (this.types.has(brand.name)) throw new Error(`@kizunajs/kotlin: brand ${stringify(brand.name)} has the same name as a model. Give each its own name.`);
107
+ this.brands.set(brand.name, brand);
108
+ }
109
+ allBrands() {
110
+ return Array.from(this.brands.values());
111
+ }
102
112
  warnJsonElement(hint, reason) {
103
113
  this.usesJsonElement = true;
104
114
  this.warningSet.add(`${hint}: ${reason}`);
@@ -117,8 +127,7 @@ const propertyName = (key, camelCase) => {
117
127
  if (!camel) return "field";
118
128
  return /^[0-9]/.test(camel) ? `_${camel}` : camel;
119
129
  };
120
- const objectFields = (schema, registry, hint) => {
121
- const shape = readObjectShape(schema) ?? {};
130
+ const objectFields = (schema, registry, hint, shape = readObjectShape(schema) ?? {}) => {
122
131
  const fields = [];
123
132
  const seen = /* @__PURE__ */ new Map();
124
133
  for (const [key, value] of Object.entries(shape)) {
@@ -133,6 +142,7 @@ const objectFields = (schema, registry, hint) => {
133
142
  wireName: key,
134
143
  type: result.expression,
135
144
  optional: result.optional,
145
+ requiredNullable: result.requiredNullable,
136
146
  description: readMetaDescription(value),
137
147
  isFile: result.isFile,
138
148
  deprecated: deprecation !== void 0,
@@ -142,12 +152,38 @@ const objectFields = (schema, registry, hint) => {
142
152
  return fields;
143
153
  };
144
154
  const stringify = (value) => JSON.stringify(value);
155
+ const BRANDABLE_TYPES = new Set([
156
+ "String",
157
+ "Int",
158
+ "Long",
159
+ "Double",
160
+ "Boolean",
161
+ "Instant"
162
+ ]);
145
163
  const mapType = (schema, registry, hint) => {
164
+ const result = mapUnbrandedType(schema, registry, hint);
165
+ const brand = readMetaBrand(schema);
166
+ if (brand === void 0 || !BRANDABLE_TYPES.has(result.expression)) return result;
167
+ const name = sanitizeIdentifier(brand);
168
+ registry.addBrand({
169
+ name,
170
+ rawType: result.expression
171
+ });
172
+ return {
173
+ expression: name,
174
+ optional: false
175
+ };
176
+ };
177
+ const mapUnbrandedType = (schema, registry, hint) => {
146
178
  if (isFileSchema(schema)) return {
147
179
  expression: "MultipartFile",
148
180
  optional: false,
149
181
  isFile: true
150
182
  };
183
+ if (isBinarySchema(schema)) return {
184
+ expression: "ByteArray",
185
+ optional: false
186
+ };
151
187
  const id = readMetaId(schema);
152
188
  const def = readDef(schema);
153
189
  const discriminated = readDiscriminatedUnion(schema);
@@ -223,12 +259,13 @@ const mapType = (schema, registry, hint) => {
223
259
  optional: false
224
260
  };
225
261
  }
226
- const { inner, optional } = unwrapOptionalWrappers(schema);
227
- if (optional) {
262
+ const { inner, optional, nullable } = unwrapOptionalWrappers(schema);
263
+ if (optional || nullable) {
228
264
  const innerResult = mapType(inner, registry, hint);
229
265
  return {
230
266
  expression: innerResult.expression.endsWith("?") ? innerResult.expression : `${innerResult.expression}?`,
231
267
  optional: true,
268
+ requiredNullable: nullable && !optional,
232
269
  isFile: innerResult.isFile
233
270
  };
234
271
  }
@@ -277,8 +314,9 @@ const mapType = (schema, registry, hint) => {
277
314
  optional: false
278
315
  };
279
316
  }
317
+ const elementType = mapType(element, registry, `${hint}Item`).expression.replace(/\?$/, "");
280
318
  return {
281
- expression: `List<${mapType(element, registry, `${hint}Item`).expression.replace(/\?$/, "")}>`,
319
+ expression: `List<${unwrapOptionalWrappers(element).nullable ? `${elementType}?` : elementType}>`,
282
320
  optional: false
283
321
  };
284
322
  }
@@ -412,6 +450,7 @@ const sanitizeCaseName = (literal) => {
412
450
  const collectObjectFields = (schema, registry, hint) => {
413
451
  return objectFields(schema, registry, hint);
414
452
  };
453
+ const hasDefault = (field) => field.optional && field.requiredNullable !== true;
415
454
  const objectFieldCount = (schema) => {
416
455
  return Object.keys(readObjectShape(schema) ?? {}).length;
417
456
  };
@@ -576,7 +615,7 @@ const buildRouteMethod = (routeKey, route, registry, deprecated, deprecationMess
576
615
  stream
577
616
  };
578
617
  };
579
- const kotlinGenerator = createGenerator((options, _api) => {
618
+ const kotlinRoutesWalk = (options) => {
580
619
  const flatMethods = [];
581
620
  const groupMap = /* @__PURE__ */ new Map();
582
621
  return {
@@ -608,7 +647,7 @@ const kotlinGenerator = createGenerator((options, _api) => {
608
647
  };
609
648
  }
610
649
  };
611
- });
650
+ };
612
651
  const KOTLIN_KEYWORDS = new Set([
613
652
  "as",
614
653
  "break",
@@ -642,6 +681,10 @@ const KOTLIN_KEYWORDS = new Set([
642
681
  const escapeKeyword = (name) => {
643
682
  return KOTLIN_KEYWORDS.has(name) ? `\`${name}\`` : name;
644
683
  };
684
+ const defaultArgument = (field) => {
685
+ if (!hasDefault(field)) return "";
686
+ return " = null";
687
+ };
645
688
  const optionalize = (type, optional) => {
646
689
  if (!optional) return type;
647
690
  return type.endsWith("?") ? type : `${type}?`;
@@ -653,8 +696,13 @@ const KOTLIN_PRIMITIVE_TYPES = new Set([
653
696
  "Long",
654
697
  "Boolean",
655
698
  "Instant",
656
- "Unit"
699
+ "Unit",
700
+ "ByteArray"
657
701
  ]);
702
+ /**
703
+ * How a response body is read. Raw bytes are the body already.
704
+ */
705
+ const decodeBody = (resolved) => resolved === "ByteArray" ? "data" : `json.decodeFromString<${resolved}>(data.decodeToString())`;
658
706
  const resolveType = (typeName, currentOperation, context, scope = "client") => {
659
707
  const { operationTypeMap, namespaceName, clientName, fileLevelTypeNames } = context;
660
708
  const optional = typeName.endsWith("?");
@@ -797,7 +845,7 @@ const emitDataClass = (writer, type, ownedTypeMap, ownedTypeLookup, registryName
797
845
  if (!hasFile) writer.line("@Serializable");
798
846
  const params = adjustedFields.map((field) => {
799
847
  const typeExpression = optionalize(field.type, field.optional);
800
- const defaultPart = field.optional ? " = null" : "";
848
+ const defaultPart = defaultArgument(field);
801
849
  const serialNameAnnotation = needsSerialName && (field.name !== field.wireName || KOTLIN_KEYWORDS.has(field.name)) ? `@SerialName(${stringLiteral(field.wireName)}) ` : "";
802
850
  return `${field.deprecated ? `${deprecatedAnnotation(field.deprecationMessage)} ` : ""}${serialNameAnnotation}val ${escapeKeyword(field.name)}: ${typeExpression}${defaultPart}`;
803
851
  });
@@ -890,7 +938,7 @@ const emitSealedClass = (writer, type, registry, ownedTypeMap, ownedTypeLookup)
890
938
  const params = fields.map((field) => {
891
939
  const variantPath = registry.sealedVariantPath(variant.payloadType) ?? variant.payloadType;
892
940
  const typeExpression = optionalize(relativeOwnedPath(field.type, ownedTypeMap, variantPath, registry) ?? ownedTypePath(field.type, ownedTypeMap, registry), field.optional);
893
- const defaultPart = field.optional ? " = null" : "";
941
+ const defaultPart = defaultArgument(field);
894
942
  return `val ${escapeKeyword(field.name)}: ${typeExpression}${defaultPart}`;
895
943
  });
896
944
  if (params.length === 1) writer.line(`data class ${variant.caseName}(${params[0]}) : ${type.name}`);
@@ -927,6 +975,16 @@ const emitType = (writer, type, ownedTypeMap = /* @__PURE__ */ new Map(), ownedT
927
975
  else if (type.kind === "enum-class") emitEnumClass(writer, type.name, type.cases, type.unknownCase, type.description);
928
976
  else if (registry) emitSealedClass(writer, type, registry, ownedTypeMap, ownedTypeLookup);
929
977
  };
978
+ /**
979
+ * A brand as a value class that serializes and prints as the bare value.
980
+ */
981
+ const emitBrand = (writer, brand) => {
982
+ writer.line("@Serializable");
983
+ writer.line("@JvmInline");
984
+ writer.block(`value class ${brand.name}(val value: ${brand.rawType})`, () => {
985
+ writer.line(`override fun toString(): String = ${brand.rawType === "String" ? "value" : "value.toString()"}`);
986
+ });
987
+ };
930
988
  const emitTypes = (writer, types, ownedTypeMap = /* @__PURE__ */ new Map(), ownedTypeLookup = /* @__PURE__ */ new Map(), registry) => {
931
989
  for (const type of types) emitType(writer, type, ownedTypeMap, ownedTypeLookup, void 0, registry);
932
990
  };
@@ -1002,7 +1060,7 @@ const emitOperationResultTypes = (writer, method, context) => {
1002
1060
  writer.line(") {");
1003
1061
  writer.indent(() => {
1004
1062
  writer.line("/** How far along the call is. */");
1005
- writer.line("enum class State { Running, Done, Failed }");
1063
+ writer.line("enum class State { Running, NeedsApproval, Done, Failed }");
1006
1064
  });
1007
1065
  writer.line("}");
1008
1066
  writer.blank();
@@ -1012,7 +1070,12 @@ const emitOperationResultTypes = (writer, method, context) => {
1012
1070
  writer.block("for (event in events)", () => {
1013
1071
  writer.line("when (event) {");
1014
1072
  writer.indent(() => {
1015
- writer.line("is Event.ToolCall -> calls[event.data.id] = (calls[event.data.id] ?: ToolCallRecord(event.data.id, event.data.name)).copy(call = event.data)");
1073
+ writer.line("is Event.ToolCall -> {");
1074
+ writer.indent(() => {
1075
+ writer.line("val tracked = (calls[event.data.id] ?: ToolCallRecord(event.data.id, event.data.name)).copy(call = event.data)");
1076
+ writer.line("calls[event.data.id] = if (event.data.needsApproval == true && tracked.state == ToolCallRecord.State.Running) tracked.copy(state = ToolCallRecord.State.NeedsApproval) else tracked");
1077
+ });
1078
+ writer.line("}");
1016
1079
  writer.line("is Event.ToolResult -> calls[event.data.id] = (calls[event.data.id] ?: ToolCallRecord(event.data.id, event.data.name)).copy(state = ToolCallRecord.State.Done, result = event.data)");
1017
1080
  if (eventNames.has("tool_error")) writer.line("is Event.ToolError -> calls[event.data.id] = (calls[event.data.id] ?: ToolCallRecord(event.data.id, event.data.name.wireValue)).copy(state = ToolCallRecord.State.Failed, message = event.data.message)");
1018
1081
  writer.line("else -> Unit");
@@ -1074,7 +1137,7 @@ const groupTypeRef = (operationName, className, context) => `${context.clientNam
1074
1137
  const groupMemberAccessor = (groupLabel, field) => `${groupLabel}.${escapeKeyword(field.name)}`;
1075
1138
  const groupClassField = (field) => {
1076
1139
  const typeExpression = optionalize(field.type, field.optional);
1077
- const defaultPart = field.optional ? " = null" : "";
1140
+ const defaultPart = defaultArgument(field);
1078
1141
  return `val ${escapeKeyword(field.name)}: ${typeExpression}${defaultPart}`;
1079
1142
  };
1080
1143
  const emitGroupClass = (writer, className, fields, method, context) => {
@@ -1133,13 +1196,13 @@ const methodChannels = (method) => {
1133
1196
  if (method.query.length > 0) channels.push({
1134
1197
  varName: "query",
1135
1198
  className: "Query",
1136
- required: method.query.some((field) => !field.optional),
1199
+ required: method.query.some((field) => !hasDefault(field)),
1137
1200
  fields: method.query
1138
1201
  });
1139
1202
  if (method.headers.length > 0) channels.push({
1140
1203
  varName: "headers",
1141
1204
  className: "Headers",
1142
- required: method.headers.some((field) => !field.optional),
1205
+ required: method.headers.some((field) => !hasDefault(field)),
1143
1206
  fields: method.headers
1144
1207
  });
1145
1208
  if (hasBodyGroup(method)) {
@@ -1188,7 +1251,7 @@ const emitArgsScaffolding = (writer, method, context) => {
1188
1251
  };
1189
1252
  const factorySignature = (channel) => channel.fields.map((field) => {
1190
1253
  const typeExpression = optionalize(field.type, field.optional);
1191
- const defaultPart = field.optional ? " = null" : "";
1254
+ const defaultPart = defaultArgument(field);
1192
1255
  return `${escapeKeyword(field.name)}: ${typeExpression}${defaultPart}`;
1193
1256
  }).join(", ");
1194
1257
  const factoryConstruction = (channel) => {
@@ -1245,10 +1308,16 @@ const emitBodyEncoding = (writer, method, context) => {
1245
1308
  if (body.kind === "multipart") {
1246
1309
  writer.line("val multipartBuilder = MultipartBody.Builder().setType(MultipartBody.FORM)");
1247
1310
  for (const field of body.multipartFields) {
1248
- const accessor = groupMemberAccessor("body", field);
1249
- const isFlattenedFile = body.flattened.find((flatField) => flatField.name === field.name)?.isFile === true;
1250
- if (field.isFile || isFlattenedFile) writer.line(`multipartBuilder.addFormDataPart(${stringLiteral(field.wireName)}, ${accessor}.filename, ${accessor}.data.toRequestBody(${accessor}.mimeType.toMediaType()))`);
1251
- else writer.line(`multipartBuilder.addFormDataPart(${stringLiteral(field.wireName)}, ${accessor}.toString())`);
1311
+ const flattenedField = body.flattened.find((flatField) => flatField.name === field.name);
1312
+ const isFile = field.isFile || flattenedField?.isFile === true;
1313
+ const append = (accessor) => {
1314
+ if (isFile) writer.line(`multipartBuilder.addFormDataPart(${stringLiteral(field.wireName)}, ${accessor}.filename, ${accessor}.data.toRequestBody(${accessor}.mimeType.toMediaType()))`);
1315
+ else writer.line(`multipartBuilder.addFormDataPart(${stringLiteral(field.wireName)}, ${accessor}.toString())`);
1316
+ };
1317
+ if (flattenedField?.optional === true) writer.block(`if (${groupMemberAccessor("body", field)} != null)`, () => {
1318
+ append(groupMemberAccessor("body", field));
1319
+ });
1320
+ else append(groupMemberAccessor("body", field));
1252
1321
  }
1253
1322
  writer.line("requestBody = multipartBuilder.build()");
1254
1323
  return;
@@ -1324,7 +1393,7 @@ const emitMethodBody = (writer, method, context) => {
1324
1393
  else setHeader(accessor);
1325
1394
  }
1326
1395
  writer.line("requestInterceptor?.invoke(requestBuilder)");
1327
- writer.line("val httpResponse = Kizuna.execute(client, requestBuilder.build())");
1396
+ writer.line("val httpResponse = Kizuna.send(client, requestBuilder.build(), retry)");
1328
1397
  if (method.stream) {
1329
1398
  emitStreamMethodTail(writer, method, context);
1330
1399
  return;
@@ -1344,7 +1413,7 @@ const emitMethodBody = (writer, method, context) => {
1344
1413
  writer.indent(() => {
1345
1414
  const resolved = resolveType(successResponse.type, method.operationName, context);
1346
1415
  writer.block("try", () => {
1347
- writer.line(`val payload = json.decodeFromString<${resolved}>(data.decodeToString())`);
1416
+ writer.line(`val payload = ${decodeBody(resolved)}`);
1348
1417
  const bodyExpr = isMultiSuccess ? `${operationRef}.Success.Status${successResponse.status}(payload)` : "payload";
1349
1418
  if (hasHeaders) {
1350
1419
  for (const field of successResponse.responseHeaders) writer.line(`val ${escapeKeyword(field.name)} = httpResponse.header(${stringLiteral(field.wireName)})`);
@@ -1381,7 +1450,7 @@ const emitErrorBranches = (writer, method, context) => {
1381
1450
  const resolved = resolveType(errorCase.type, method.operationName, context);
1382
1451
  writer.line("val payload = try {");
1383
1452
  writer.indent(() => {
1384
- writer.line(`json.decodeFromString<${resolved}>(data.decodeToString())`);
1453
+ writer.line(decodeBody(resolved));
1385
1454
  });
1386
1455
  writer.line(`} catch (error: Exception) { throw ${failureRef}.Decoding(error, statusCode, data) }`);
1387
1456
  writer.line(`throw ${failureRef}.${caseName}(body = payload)`);
@@ -1394,7 +1463,7 @@ const emitErrorBranches = (writer, method, context) => {
1394
1463
  const resolved = resolveType(errorCase.type, method.operationName, context);
1395
1464
  writer.line(`val ${errorCase.caseName} = try {`);
1396
1465
  writer.indent(() => {
1397
- writer.line(`json.decodeFromString<${resolved}>(data.decodeToString())`);
1466
+ writer.line(decodeBody(resolved));
1398
1467
  });
1399
1468
  writer.line("} catch (_: Exception) { null }");
1400
1469
  writer.line(`if (${errorCase.caseName} != null) throw ${failureRef}.${caseName}(body = ${errorCase.caseName})`);
@@ -1476,7 +1545,7 @@ const emitSubClientMethod = (writer, method, context) => {
1476
1545
  };
1477
1546
  const emitSubClientClass = (writer, group, clientName, context) => {
1478
1547
  writer.blank();
1479
- writer.block(`class ${group.className}(private val client: OkHttpClient, private val baseUrl: String, private val json: Json, private val requestContextHeaders: Map<String, String>, private val requestInterceptor: (suspend (Request.Builder) -> Unit)?, private val responseInterceptor: (suspend (Request, Response) -> Unit)?)`, () => {
1548
+ writer.block(`class ${group.className}(private val client: OkHttpClient, private val baseUrl: String, private val json: Json, private val requestContextHeaders: Map<String, String>, private val requestInterceptor: (suspend (Request.Builder) -> Unit)?, private val responseInterceptor: (suspend (Request, Response) -> Unit)?, private val retry: ${clientName}.RetryPolicy?)`, () => {
1480
1549
  for (const method of group.methods) {
1481
1550
  writer.blank();
1482
1551
  emitSubClientMethod(writer, method, context);
@@ -1581,6 +1650,47 @@ const emitKizunaObject = (writer, options) => {
1581
1650
  writer.line("}");
1582
1651
  });
1583
1652
  writer.blank();
1653
+ writer.block(`suspend fun send(client: OkHttpClient, request: Request, retry: ${options.clientName}.RetryPolicy?): Response`, () => {
1654
+ writer.line("val idempotent = request.method in setOf(\"GET\", \"HEAD\", \"OPTIONS\", \"PUT\", \"DELETE\")");
1655
+ writer.line("val policy = retry?.takeIf { idempotent && request.body?.isOneShot() != true }");
1656
+ writer.line("var attempt = 1");
1657
+ writer.block("while (true)", () => {
1658
+ writer.line("val response = try {");
1659
+ writer.line(" execute(client, request)");
1660
+ writer.line("} catch (error: java.io.IOException) {");
1661
+ writer.line(" val delay = policy?.let { retryDelay(it, attempt, null) } ?: throw error");
1662
+ writer.line(" kotlinx.coroutines.delay(delay)");
1663
+ writer.line(" attempt += 1");
1664
+ writer.line(" continue");
1665
+ writer.line("}");
1666
+ writer.line("val delay = policy?.let { retryDelay(it, attempt, response) } ?: return response");
1667
+ writer.line("response.close()");
1668
+ writer.line("kotlinx.coroutines.delay(delay)");
1669
+ writer.line("attempt += 1");
1670
+ });
1671
+ });
1672
+ writer.blank();
1673
+ writer.block(`fun retryDelay(policy: ${options.clientName}.RetryPolicy, attempt: Int, response: Response?): Long?`, () => {
1674
+ writer.line("if (attempt >= policy.maximumAttempts) return null");
1675
+ writer.line("val ceiling = minOf(policy.maximumDelay.inWholeMilliseconds, policy.initialDelay.inWholeMilliseconds shl (attempt - 1))");
1676
+ writer.line("val backoff = (Math.random() * ceiling).toLong()");
1677
+ writer.line("if (response == null) return backoff");
1678
+ writer.line("if (response.code != 429 && response.code != 503) return null");
1679
+ writer.line("val asked = retryAfter(response.header(\"Retry-After\")) ?: return backoff");
1680
+ writer.line("return if (asked <= policy.maximumDelay.inWholeMilliseconds) asked else null");
1681
+ });
1682
+ writer.blank();
1683
+ writer.block("fun retryAfter(header: String?): Long?", () => {
1684
+ writer.line("val value = header?.trim() ?: return null");
1685
+ writer.line("value.toLongOrNull()?.let { return if (it >= 0) it * 1000 else null }");
1686
+ writer.line("val date = try {");
1687
+ writer.line(" java.time.ZonedDateTime.parse(value, java.time.format.DateTimeFormatter.RFC_1123_DATE_TIME)");
1688
+ writer.line("} catch (_: java.time.format.DateTimeParseException) {");
1689
+ writer.line(" return null");
1690
+ writer.line("}");
1691
+ writer.line("return maxOf(0L, date.toInstant().toEpochMilli() - System.currentTimeMillis())");
1692
+ });
1693
+ writer.blank();
1584
1694
  writer.block("suspend fun execute(client: OkHttpClient, request: Request): Response", () => {
1585
1695
  writer.line("return kotlinx.coroutines.suspendCancellableCoroutine { continuation ->");
1586
1696
  writer.indent(() => {
@@ -1608,20 +1718,34 @@ const emitClient = (writer, config, partition, context, typesByOperation, regist
1608
1718
  const allMethods = [...flatMethods, ...groups.flatMap((group) => group.methods)];
1609
1719
  const usesMultipart = allMethods.some((method) => method.body?.kind === "multipart");
1610
1720
  const contextFields = context.requestContextFields;
1611
- const contextRequired = contextFields.some((field) => !field.optional);
1721
+ const contextRequired = contextFields.some((field) => !hasDefault(field));
1612
1722
  const contextParam = contextFields.length > 0 ? `requestContext: RequestContext${contextRequired ? "" : " = RequestContext()"}, ` : "";
1613
1723
  writer.blank();
1614
- writer.block(`class ${clientName}(private val baseUrl: String, ${contextParam}private val client: OkHttpClient = OkHttpClient(), private val json: Json = Json { ignoreUnknownKeys = true }, private val requestInterceptor: (suspend (Request.Builder) -> Unit)? = null, private val responseInterceptor: (suspend (Request, Response) -> Unit)? = null)`, () => {
1724
+ writer.block(`class ${clientName}(private val baseUrl: String, ${contextParam}private val client: OkHttpClient = OkHttpClient(), private val json: Json = Json { ignoreUnknownKeys = true }, private val requestInterceptor: (suspend (Request.Builder) -> Unit)? = null, private val responseInterceptor: (suspend (Request, Response) -> Unit)? = null, private val retry: RetryPolicy? = null)`, () => {
1615
1725
  if (contextFields.length > 0) {
1616
1726
  writer.blank();
1617
1727
  writer.line("/** Values sent as headers on every request, from the api's request context. */");
1618
- emitConstructorClass(writer, "data class RequestContext", contextFields.map((field) => `val ${escapeKeyword(field.name)}: ${field.type}${field.optional ? " = null" : ""}`));
1728
+ emitConstructorClass(writer, "data class RequestContext", contextFields.map((field) => `val ${escapeKeyword(field.name)}: ${field.type}${defaultArgument(field)}`));
1619
1729
  writer.blank();
1620
1730
  writer.block("private val requestContextHeaders: Map<String, String> = buildMap", () => {
1621
1731
  for (const field of contextFields) if (field.optional) writer.line(`if (requestContext.${escapeKeyword(field.name)} != null) put(${stringLiteral(field.wireName)}, requestContext.${escapeKeyword(field.name)}!!)`);
1622
1732
  else writer.line(`put(${stringLiteral(field.wireName)}, requestContext.${escapeKeyword(field.name)})`);
1623
1733
  });
1624
1734
  }
1735
+ writer.blank();
1736
+ writer.line("/**");
1737
+ writer.line(" * Retries a GET, HEAD, OPTIONS, PUT or DELETE that failed on the network or was answered 429 or 503,");
1738
+ writer.line(" * waiting what Retry-After asks or backing off.");
1739
+ writer.line(" *");
1740
+ writer.line(" * @property maximumAttempts Attempts in all, the first included.");
1741
+ writer.line(" * @property initialDelay What the first backoff waits at most.");
1742
+ writer.line(" * @property maximumDelay What any wait may take.");
1743
+ writer.line(" */");
1744
+ emitConstructorClass(writer, "data class RetryPolicy", [
1745
+ "val maximumAttempts: Int = 3",
1746
+ "val initialDelay: kotlin.time.Duration = 500.milliseconds",
1747
+ "val maximumDelay: kotlin.time.Duration = 30.seconds"
1748
+ ]);
1625
1749
  if (usesMultipart) {
1626
1750
  writer.blank();
1627
1751
  emitConstructorClass(writer, "data class MultipartFile", [
@@ -1677,7 +1801,7 @@ const emitClient = (writer, config, partition, context, typesByOperation, regist
1677
1801
  }
1678
1802
  if (groups.length > 0) for (const group of groups) {
1679
1803
  writer.blank();
1680
- writer.line(`val ${escapeKeyword(group.propertyName)} = ${group.className}(client, baseUrl, json, ${contextFields.length > 0 ? "requestContextHeaders" : "emptyMap()"}, requestInterceptor, responseInterceptor)`);
1804
+ writer.line(`val ${escapeKeyword(group.propertyName)} = ${group.className}(client, baseUrl, json, ${contextFields.length > 0 ? "requestContextHeaders" : "emptyMap()"}, requestInterceptor, responseInterceptor, retry)`);
1681
1805
  }
1682
1806
  for (const method of flatMethods) {
1683
1807
  writer.blank();
@@ -1685,21 +1809,8 @@ const emitClient = (writer, config, partition, context, typesByOperation, regist
1685
1809
  }
1686
1810
  });
1687
1811
  };
1688
- /**
1689
- * Generate a Kotlin API client from a Kizuna api.
1690
- *
1691
- * @param api - What `defineConfig` assembled.
1692
- * @param config - Override the generated names:
1693
- * - `namespaceName`: the object wrapping shared types.
1694
- * - `packageName`: optional package declaration for the generated file.
1695
- */
1696
- const generateKotlinClient = (api, config) => {
1697
- const { namespaceName, packageName, camelCaseProperties = false, unknownEnumCase = false } = config;
1698
- const registry = new TypeRegistry(camelCaseProperties, unknownEnumCase);
1699
- const partition = kotlinGenerator(api, {
1700
- namespaceName,
1701
- registry
1702
- });
1812
+ const renderKotlinClient = (api, partition, registry, config) => {
1813
+ const { namespaceName, packageName, camelCaseProperties = false } = config;
1703
1814
  const allMethods = [...partition.flatMethods, ...partition.groups.flatMap((group) => group.methods)];
1704
1815
  const operationTypeMap = buildOperationTypeMap(allMethods, registry);
1705
1816
  const clientName = `${namespaceName}Client`;
@@ -1735,6 +1846,8 @@ const generateKotlinClient = (api, config) => {
1735
1846
  writer.line("import okhttp3.*");
1736
1847
  writer.line("import okhttp3.MediaType.Companion.toMediaType");
1737
1848
  writer.line("import okhttp3.RequestBody.Companion.toRequestBody");
1849
+ writer.line("import kotlin.time.Duration.Companion.milliseconds");
1850
+ writer.line("import kotlin.time.Duration.Companion.seconds");
1738
1851
  writer.line("import okhttp3.HttpUrl.Companion.toHttpUrl");
1739
1852
  writer.blank();
1740
1853
  if (registry.all().some((type) => type.kind === "enum-class")) {
@@ -1784,12 +1897,42 @@ const generateKotlinClient = (api, config) => {
1784
1897
  };
1785
1898
  writer.block(`object ${namespaceName}`, () => {
1786
1899
  emitTypes(writer, topLevelSharedTypes, ownedTypeMap, ownedTypeLookup, registry);
1900
+ for (const brand of registry.allBrands()) {
1901
+ writer.blank();
1902
+ emitBrand(writer, brand);
1903
+ }
1787
1904
  });
1788
1905
  emitClient(writer, { clientName }, partition, context, typesByOperation, registry);
1789
1906
  for (const group of partition.groups) emitSubClientClass(writer, group, clientName, context);
1790
- emitKizunaObject(writer, { streaming: usesStreaming });
1907
+ emitKizunaObject(writer, {
1908
+ streaming: usesStreaming,
1909
+ clientName
1910
+ });
1791
1911
  for (const warning of registry.warnings()) process.stderr.write(`[kizuna/kotlin] JsonElement fallback at ${warning}\n`);
1792
1912
  return writer.toString();
1793
1913
  };
1914
+ /**
1915
+ * The Kotlin client's walk: the routes into a partition, then the file.
1916
+ */
1917
+ const kotlinClientWalk = (api, config) => {
1918
+ const registry = new TypeRegistry(config.camelCaseProperties ?? false, config.unknownEnumCase ?? false);
1919
+ const routes = kotlinRoutesWalk({
1920
+ namespaceName: config.namespaceName,
1921
+ registry
1922
+ });
1923
+ return {
1924
+ processRoute: routes.processRoute,
1925
+ finalize: () => renderKotlinClient(api, routes.finalize(), registry, config)
1926
+ };
1927
+ };
1928
+ /**
1929
+ * Generate a Kotlin API client from a Kizuna api.
1930
+ *
1931
+ * @param api - What `defineConfig` assembled.
1932
+ * @param config - Override the generated names:
1933
+ * - `namespaceName`: the object wrapping shared types.
1934
+ * - `packageName`: optional package declaration for the generated file.
1935
+ */
1936
+ const generateKotlinClient = (api, config) => walkApi(api, kotlinClientWalk(api, config), { skipHidden: true });
1794
1937
  //#endregion
1795
- export { generateKotlinClient as t };
1938
+ export { kotlinClientWalk as n, generateKotlinClient as t };
package/dist/index.cjs CHANGED
@@ -1,17 +1,48 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_generator = require("./generator-s2pn7vqP.cjs");
2
+ const require_generator = require("./generator-Bg4HlRb3.cjs");
3
+ let kizunajs_generator = require("kizunajs/generator");
4
+ let zod = require("zod");
3
5
  //#region src/client-target.ts
6
+ const KotlinClientOptionsSchema = zod.z.object({
7
+ /**
8
+ * Name of the generated namespace object.
9
+ *
10
+ * @default 'API'
11
+ */
12
+ namespace: zod.z.string().default("API"),
13
+ /**
14
+ * Package declaration for the generated file, e.g. `com.example.api`.
15
+ */
16
+ package: zod.z.string().optional(),
17
+ /**
18
+ * Convert wire field names to camelCase properties, mapping the wire name
19
+ * back via `@SerialName`.
20
+ *
21
+ * @default false
22
+ */
23
+ camelCaseProperties: zod.z.boolean().optional(),
24
+ /**
25
+ * Give every enum an `unknown` case, so a value the client has not been
26
+ * generated for decodes instead of throwing.
27
+ *
28
+ * @default false
29
+ */
30
+ unknownEnumCase: zod.z.boolean().optional()
31
+ });
4
32
  /**
5
- * A Kotlin client target for `kizuna.config.ts`.
33
+ * A Kotlin client for `kizuna.config.ts`.
6
34
  *
7
35
  * @example
8
- * kotlinClient({ output: './api/APIClient.kt', package: 'com.example.api' });
36
+ * kotlinClient({
37
+ * output: './api/APIClient.kt',
38
+ * package: 'com.example.api',
39
+ * });
9
40
  */
10
- const kotlinClient = (options) => ({
11
- kind: "kotlin",
12
- output: options.output,
13
- generate: (contract) => require_generator.generateKotlinClient(contract, {
14
- namespaceName: options.namespace ?? "API",
41
+ const kotlinClient = (0, kizunajs_generator.defineClient)({
42
+ target: "kotlin",
43
+ options: KotlinClientOptionsSchema,
44
+ generate: ({ options, api }) => require_generator.kotlinClientWalk(api, {
45
+ namespaceName: options.namespace,
15
46
  packageName: options.package,
16
47
  camelCaseProperties: options.camelCaseProperties,
17
48
  unknownEnumCase: options.unknownEnumCase
package/dist/index.d.cts CHANGED
@@ -1,4 +1,7 @@
1
- import { ApiDefinition, ClientTarget } from "kizunajs";
1
+ import { GeneratedFileOptions } from "kizunajs/generator";
2
+ import * as _$kizunajs from "kizunajs";
3
+ import { ApiDefinition } from "kizunajs";
4
+ import { z } from "zod";
2
5
 
3
6
  //#region src/generator.d.ts
4
7
  interface KotlinConfig {
@@ -38,45 +41,30 @@ interface KotlinConfig {
38
41
  declare const generateKotlinClient: (api: ApiDefinition, config: KotlinConfig) => string;
39
42
  //#endregion
40
43
  //#region src/client-target.d.ts
44
+ declare const KotlinClientOptionsSchema: z.ZodObject<{
45
+ namespace: z.ZodDefault<z.ZodString>;
46
+ package: z.ZodOptional<z.ZodString>;
47
+ camelCaseProperties: z.ZodOptional<z.ZodBoolean>;
48
+ unknownEnumCase: z.ZodOptional<z.ZodBoolean>;
49
+ }, z.core.$strip>;
41
50
  /**
42
51
  * Where a generated Kotlin client is written, and what it is called there.
43
52
  */
44
- interface KotlinClientOptions {
45
- /**
46
- * Path the generated file is written to.
47
- */
48
- output: string;
49
- /**
50
- * Name of the generated namespace object.
51
- *
52
- * @default 'API'
53
- */
54
- namespace?: string;
55
- /**
56
- * Package declaration for the generated file, e.g. `com.example.api`.
57
- */
58
- package?: string;
59
- /**
60
- * Convert wire field names to camelCase properties, mapping the wire name
61
- * back via `@SerialName`.
62
- *
63
- * @default false
64
- */
65
- camelCaseProperties?: boolean;
66
- /**
67
- * Give every enum an `unknown` case, so a value the client has not been
68
- * generated for decodes instead of throwing.
69
- *
70
- * @default false
71
- */
72
- unknownEnumCase?: boolean;
73
- }
53
+ type KotlinClientOptions = z.input<typeof KotlinClientOptionsSchema> & GeneratedFileOptions;
74
54
  /**
75
- * A Kotlin client target for `kizuna.config.ts`.
55
+ * A Kotlin client for `kizuna.config.ts`.
76
56
  *
77
57
  * @example
78
- * kotlinClient({ output: './api/APIClient.kt', package: 'com.example.api' });
58
+ * kotlinClient({
59
+ * output: './api/APIClient.kt',
60
+ * package: 'com.example.api',
61
+ * });
79
62
  */
80
- declare const kotlinClient: (options: KotlinClientOptions) => ClientTarget;
63
+ declare const kotlinClient: (options: {
64
+ namespace?: string | undefined;
65
+ package?: string | undefined;
66
+ camelCaseProperties?: boolean | undefined;
67
+ unknownEnumCase?: boolean | undefined;
68
+ } & GeneratedFileOptions) => _$kizunajs.GeneratedFile;
81
69
  //#endregion
82
70
  export { type KotlinClientOptions, type KotlinConfig, generateKotlinClient, kotlinClient };
package/dist/index.d.mts CHANGED
@@ -1,4 +1,7 @@
1
- import { ApiDefinition, ClientTarget } from "kizunajs";
1
+ import { GeneratedFileOptions } from "kizunajs/generator";
2
+ import { z } from "zod";
3
+ import * as _$kizunajs from "kizunajs";
4
+ import { ApiDefinition } from "kizunajs";
2
5
 
3
6
  //#region src/generator.d.ts
4
7
  interface KotlinConfig {
@@ -38,45 +41,30 @@ interface KotlinConfig {
38
41
  declare const generateKotlinClient: (api: ApiDefinition, config: KotlinConfig) => string;
39
42
  //#endregion
40
43
  //#region src/client-target.d.ts
44
+ declare const KotlinClientOptionsSchema: z.ZodObject<{
45
+ namespace: z.ZodDefault<z.ZodString>;
46
+ package: z.ZodOptional<z.ZodString>;
47
+ camelCaseProperties: z.ZodOptional<z.ZodBoolean>;
48
+ unknownEnumCase: z.ZodOptional<z.ZodBoolean>;
49
+ }, z.core.$strip>;
41
50
  /**
42
51
  * Where a generated Kotlin client is written, and what it is called there.
43
52
  */
44
- interface KotlinClientOptions {
45
- /**
46
- * Path the generated file is written to.
47
- */
48
- output: string;
49
- /**
50
- * Name of the generated namespace object.
51
- *
52
- * @default 'API'
53
- */
54
- namespace?: string;
55
- /**
56
- * Package declaration for the generated file, e.g. `com.example.api`.
57
- */
58
- package?: string;
59
- /**
60
- * Convert wire field names to camelCase properties, mapping the wire name
61
- * back via `@SerialName`.
62
- *
63
- * @default false
64
- */
65
- camelCaseProperties?: boolean;
66
- /**
67
- * Give every enum an `unknown` case, so a value the client has not been
68
- * generated for decodes instead of throwing.
69
- *
70
- * @default false
71
- */
72
- unknownEnumCase?: boolean;
73
- }
53
+ type KotlinClientOptions = z.input<typeof KotlinClientOptionsSchema> & GeneratedFileOptions;
74
54
  /**
75
- * A Kotlin client target for `kizuna.config.ts`.
55
+ * A Kotlin client for `kizuna.config.ts`.
76
56
  *
77
57
  * @example
78
- * kotlinClient({ output: './api/APIClient.kt', package: 'com.example.api' });
58
+ * kotlinClient({
59
+ * output: './api/APIClient.kt',
60
+ * package: 'com.example.api',
61
+ * });
79
62
  */
80
- declare const kotlinClient: (options: KotlinClientOptions) => ClientTarget;
63
+ declare const kotlinClient: (options: {
64
+ namespace?: string | undefined;
65
+ package?: string | undefined;
66
+ camelCaseProperties?: boolean | undefined;
67
+ unknownEnumCase?: boolean | undefined;
68
+ } & GeneratedFileOptions) => _$kizunajs.GeneratedFile;
81
69
  //#endregion
82
70
  export { type KotlinClientOptions, type KotlinConfig, generateKotlinClient, kotlinClient };
package/dist/index.mjs CHANGED
@@ -1,16 +1,45 @@
1
- import { t as generateKotlinClient } from "./generator-BvNpSarY.mjs";
2
- //#region src/client-target.ts
1
+ import { n as kotlinClientWalk, t as generateKotlinClient } from "./generator-D4GvH1-a.mjs";
2
+ import { defineClient } from "kizunajs/generator";
3
+ import { z } from "zod";
3
4
  /**
4
- * A Kotlin client target for `kizuna.config.ts`.
5
+ * A Kotlin client for `kizuna.config.ts`.
5
6
  *
6
7
  * @example
7
- * kotlinClient({ output: './api/APIClient.kt', package: 'com.example.api' });
8
+ * kotlinClient({
9
+ * output: './api/APIClient.kt',
10
+ * package: 'com.example.api',
11
+ * });
8
12
  */
9
- const kotlinClient = (options) => ({
10
- kind: "kotlin",
11
- output: options.output,
12
- generate: (contract) => generateKotlinClient(contract, {
13
- namespaceName: options.namespace ?? "API",
13
+ const kotlinClient = defineClient({
14
+ target: "kotlin",
15
+ options: z.object({
16
+ /**
17
+ * Name of the generated namespace object.
18
+ *
19
+ * @default 'API'
20
+ */
21
+ namespace: z.string().default("API"),
22
+ /**
23
+ * Package declaration for the generated file, e.g. `com.example.api`.
24
+ */
25
+ package: z.string().optional(),
26
+ /**
27
+ * Convert wire field names to camelCase properties, mapping the wire name
28
+ * back via `@SerialName`.
29
+ *
30
+ * @default false
31
+ */
32
+ camelCaseProperties: z.boolean().optional(),
33
+ /**
34
+ * Give every enum an `unknown` case, so a value the client has not been
35
+ * generated for decodes instead of throwing.
36
+ *
37
+ * @default false
38
+ */
39
+ unknownEnumCase: z.boolean().optional()
40
+ }),
41
+ generate: ({ options, api }) => kotlinClientWalk(api, {
42
+ namespaceName: options.namespace,
14
43
  packageName: options.package,
15
44
  camelCaseProperties: options.camelCaseProperties,
16
45
  unknownEnumCase: options.unknownEnumCase
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kizunajs/kotlin",
3
- "version": "2.0.0-beta.2",
3
+ "version": "2.0.0-beta.20",
4
4
  "description": "Kotlin client generation for Kizuna.js APIs",
5
5
  "keywords": [
6
6
  "kizunajs",
@@ -60,18 +60,18 @@
60
60
  "access": "public"
61
61
  },
62
62
  "dependencies": {
63
- "@kizunajs/cli": "2.0.0-beta.2"
63
+ "@kizunajs/cli": "2.0.0-beta.20"
64
64
  },
65
65
  "peerDependencies": {
66
- "zod": "^4.0.0",
67
- "kizunajs": "2.0.0-beta.2"
66
+ "kizunajs": "2.0.0-beta.20",
67
+ "zod": "^4.0.0"
68
68
  },
69
69
  "devDependencies": {
70
+ "kizunajs": "2.0.0-beta.20",
70
71
  "@types/node": "^22.9.0",
71
72
  "tsdown": "^0.21.0",
72
73
  "typescript": "^6.0.3",
73
- "zod": "^4.0.0",
74
- "kizunajs": "2.0.0-beta.2"
74
+ "zod": "^4.0.0"
75
75
  },
76
76
  "scripts": {
77
77
  "build": "tsdown src/index.ts src/cli.ts --format esm,cjs --dts --clean --external kizunajs --external @kizunajs/cli --external zod",