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

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.
@@ -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})`);
@@ -1407,6 +1476,35 @@ const emitErrorBranches = (writer, method, context) => {
1407
1476
  }
1408
1477
  writer.line(`else -> throw ${failureRef}.Unexpected(statusCode = statusCode, data = data)`);
1409
1478
  };
1479
+ const IDEMPOTENT_METHODS = new Set([
1480
+ "GET",
1481
+ "HEAD",
1482
+ "OPTIONS",
1483
+ "PUT",
1484
+ "DELETE"
1485
+ ]);
1486
+ /**
1487
+ * Writes the reconnection an event stream reopens through and returns the argument handing it to `Kizuna.events`, or
1488
+ * nothing for a method a client may not resend.
1489
+ */
1490
+ const emitReopen = (writer, method, operationRef, status) => {
1491
+ if (!IDEMPOTENT_METHODS.has(method.method)) return "";
1492
+ closure(writer, "val reconnection = reconnect?.let { policy ->", () => {
1493
+ closure(writer, "Kizuna.Reconnection(policy) { lastEventId ->", () => {
1494
+ writer.line("val again = requestBuilder.build().newBuilder()");
1495
+ writer.line("if (lastEventId.isNotEmpty()) again.header(\"Last-Event-ID\", lastEventId)");
1496
+ writer.line("requestInterceptor?.invoke(again)");
1497
+ writer.line("val reopened = Kizuna.send(client, again.build(), retry)");
1498
+ writer.line("val eventStream = reopened.header(\"Content-Type\")?.lowercase()?.startsWith(\"text/event-stream\") == true");
1499
+ writer.block("when", () => {
1500
+ writer.line("reopened.code == 204 -> { reopened.close(); null }");
1501
+ writer.line(`reopened.code == ${status} && eventStream -> reopened`);
1502
+ writer.line(`else -> { reopened.close(); throw ${operationRef}.Failure.Unexpected(statusCode = reopened.code, data = ByteArray(0)) }`);
1503
+ });
1504
+ });
1505
+ });
1506
+ return ", reconnection";
1507
+ };
1410
1508
  const emitStreamMethodTail = (writer, method, context) => {
1411
1509
  const operationRef = `${context.clientName}.${method.operationName}`;
1412
1510
  const stream = method.stream;
@@ -1419,7 +1517,7 @@ const emitStreamMethodTail = (writer, method, context) => {
1419
1517
  else if (stream.events) {
1420
1518
  const events = stream.events;
1421
1519
  const elementType = streamElementType(method, context, "client");
1422
- closure(writer, `val body = Kizuna.events<${elementType}>(httpResponse) { event ->`, () => {
1520
+ closure(writer, `val body = Kizuna.events<${elementType}>(httpResponse${emitReopen(writer, method, operationRef, stream.status)}) { event ->`, () => {
1423
1521
  writer.block("when (event.event)", () => {
1424
1522
  for (const event of events) {
1425
1523
  const payloadType = resolveType(event.type, method.operationName, context);
@@ -1430,7 +1528,8 @@ const emitStreamMethodTail = (writer, method, context) => {
1430
1528
  });
1431
1529
  } else {
1432
1530
  const elementType = streamElementType(method, context, "client");
1433
- writer.line(`val body = Kizuna.events<${elementType}>(httpResponse) { event -> json.decodeFromString<${elementType}>(event.data) }`);
1531
+ const reconnection = emitReopen(writer, method, operationRef, stream.status);
1532
+ writer.line(`val body = Kizuna.events<${elementType}>(httpResponse${reconnection}) { event -> json.decodeFromString<${elementType}>(event.data) }`);
1434
1533
  }
1435
1534
  if (hasHeaders) {
1436
1535
  for (const field of successResponse.responseHeaders) writer.line(`val ${escapeKeyword(field.name)} = httpResponse.header(${stringLiteral(field.wireName)})`);
@@ -1476,7 +1575,7 @@ const emitSubClientMethod = (writer, method, context) => {
1476
1575
  };
1477
1576
  const emitSubClientClass = (writer, group, clientName, context) => {
1478
1577
  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)?)`, () => {
1578
+ 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?, private val reconnect: ${clientName}.ReconnectPolicy?)`, () => {
1480
1579
  for (const method of group.methods) {
1481
1580
  writer.blank();
1482
1581
  emitSubClientMethod(writer, method, context);
@@ -1488,10 +1587,12 @@ const closure = (writer, opener, body) => {
1488
1587
  writer.indent(body);
1489
1588
  writer.line("}");
1490
1589
  };
1491
- const emitKizunaStreaming = (writer) => {
1590
+ const emitKizunaStreaming = (writer, clientName) => {
1492
1591
  writer.blank();
1493
1592
  writer.line("data class ServerSentEvent(val event: String?, val data: String, val id: String?, val retry: Int?)");
1494
1593
  writer.blank();
1594
+ writer.line(`class Reconnection(val policy: ${clientName}.ReconnectPolicy, val reopen: suspend (String) -> Response?)`);
1595
+ writer.blank();
1495
1596
  writer.block("private suspend fun readLine(source: okio.BufferedSource): String?", () => {
1496
1597
  writer.line("return kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { source.readUtf8Line() }");
1497
1598
  });
@@ -1518,44 +1619,83 @@ const emitKizunaStreaming = (writer) => {
1518
1619
  });
1519
1620
  });
1520
1621
  writer.blank();
1521
- writer.block("fun serverSentEvents(response: Response): Flow<ServerSentEvent> = flow", () => {
1522
- closure(writer, "response.use { open ->", () => {
1523
- writer.line("val source = open.body?.source() ?: return@use");
1524
- writer.line("var eventType = \"\"");
1525
- writer.line("val dataLines = mutableListOf<String>()");
1526
- writer.line("var lastEventId = \"\"");
1527
- writer.line("var retry: Int? = null");
1528
- writer.block("while (true)", () => {
1529
- writer.line("val line = readLine(source) ?: break");
1530
- writer.block("if (line.isEmpty())", () => {
1531
- writer.block("if (dataLines.isNotEmpty())", () => {
1532
- writer.line("emit(ServerSentEvent(eventType.ifEmpty { null }, dataLines.joinToString(\"\\n\"), lastEventId.ifEmpty { null }, retry))");
1533
- writer.line("dataLines.clear()");
1534
- writer.line("retry = null");
1622
+ writer.line("private val LINE_ENDINGS = okio.ByteString.of('\\r'.code.toByte(), '\\n'.code.toByte())");
1623
+ writer.blank();
1624
+ writer.block("private suspend fun readEventLine(source: okio.BufferedSource): String?", () => {
1625
+ closure(writer, "return kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {", () => {
1626
+ writer.line("val end = source.indexOfElement(LINE_ENDINGS)");
1627
+ writer.block("if (end == -1L)", () => {
1628
+ writer.line("if (source.exhausted()) null else source.readUtf8()");
1629
+ });
1630
+ writer.block("else", () => {
1631
+ writer.line("val line = source.readUtf8(end)");
1632
+ writer.line("val carriageReturn = source.readByte() == '\\r'.code.toByte()");
1633
+ writer.line("if (carriageReturn && source.request(1) && source.buffer[0] == '\\n'.code.toByte()) source.skip(1)");
1634
+ writer.line("line");
1635
+ });
1636
+ });
1637
+ });
1638
+ writer.blank();
1639
+ writer.block("fun serverSentEvents(response: Response, reconnect: Reconnection?): Flow<ServerSentEvent> = flow", () => {
1640
+ writer.line("var lastEventId = \"\"");
1641
+ writer.line("var reconnectionTime = reconnect?.policy?.reconnectionTime ?: 3.seconds");
1642
+ writer.line("var current: Response? = response");
1643
+ writer.line("var attempts = 0");
1644
+ writer.line("var failure: Throwable? = null");
1645
+ writer.block("while (true)", () => {
1646
+ closure(writer, "current?.use { open ->", () => {
1647
+ writer.line("val source = open.body?.source() ?: return@use");
1648
+ writer.line("var eventType = \"\"");
1649
+ writer.line("val dataLines = mutableListOf<String>()");
1650
+ writer.line("var pendingId = lastEventId");
1651
+ writer.line("var retry: Int? = null");
1652
+ writer.line("var firstLine = true");
1653
+ writer.block("while (true)", () => {
1654
+ writer.line("val received = try { readEventLine(source) } catch (error: java.io.IOException) { failure = error; null } ?: break");
1655
+ writer.line("val line = if (firstLine) received.removePrefix(\"\\uFEFF\") else received");
1656
+ writer.line("firstLine = false");
1657
+ writer.block("if (line.isEmpty())", () => {
1658
+ writer.line("lastEventId = pendingId");
1659
+ writer.block("if (dataLines.isNotEmpty())", () => {
1660
+ writer.line("emit(ServerSentEvent(eventType.ifEmpty { null }, dataLines.joinToString(\"\\n\"), lastEventId.ifEmpty { null }, retry))");
1661
+ writer.line("attempts = 0");
1662
+ writer.line("failure = null");
1663
+ writer.line("dataLines.clear()");
1664
+ writer.line("retry = null");
1665
+ });
1666
+ writer.line("eventType = \"\"");
1667
+ writer.line("continue");
1668
+ });
1669
+ writer.line("if (line.startsWith(\":\")) continue");
1670
+ writer.line("val separator = line.indexOf(':')");
1671
+ writer.line("val field = if (separator == -1) line else line.substring(0, separator)");
1672
+ writer.line("val value = (if (separator == -1) \"\" else line.substring(separator + 1)).removePrefix(\" \")");
1673
+ writer.block("when (field)", () => {
1674
+ writer.line("\"event\" -> eventType = value");
1675
+ writer.line("\"data\" -> dataLines.add(value)");
1676
+ writer.line("\"id\" -> if (!value.contains('\\u0000')) pendingId = value");
1677
+ writer.line("\"retry\" -> if (value.isNotEmpty() && value.all { character -> character in '0'..'9' }) value.toIntOrNull()?.let { milliseconds -> retry = milliseconds; reconnectionTime = minOf(milliseconds.milliseconds, 3_600_000.milliseconds) }");
1535
1678
  });
1536
- writer.line("eventType = \"\"");
1537
- writer.line("continue");
1538
- });
1539
- writer.line("if (line.startsWith(\":\")) continue");
1540
- writer.line("val separator = line.indexOf(':')");
1541
- writer.line("val field = if (separator == -1) line else line.substring(0, separator)");
1542
- writer.line("val value = (if (separator == -1) \"\" else line.substring(separator + 1)).removePrefix(\" \")");
1543
- writer.block("when (field)", () => {
1544
- writer.line("\"event\" -> eventType = value");
1545
- writer.line("\"data\" -> dataLines.add(value)");
1546
- writer.line("\"id\" -> if (!value.contains('\\u0000')) lastEventId = value");
1547
- writer.line("\"retry\" -> if (value.isNotEmpty() && value.all { it.isDigit() }) retry = value.toIntOrNull()");
1548
1679
  });
1549
1680
  });
1681
+ writer.block("if (reconnect == null || attempts >= reconnect.policy.maximumAttempts)", () => {
1682
+ writer.line("failure?.let { throw it }");
1683
+ writer.line("return@flow");
1684
+ });
1685
+ writer.line("attempts += 1");
1686
+ writer.line("val ceiling = minOf(reconnect.policy.maximumDelay, reconnectionTime * Math.pow(2.0, (attempts - 1).toDouble()))");
1687
+ writer.line("val extra = (ceiling - reconnectionTime).coerceAtLeast(kotlin.time.Duration.ZERO)");
1688
+ writer.line("kotlinx.coroutines.delay(reconnectionTime + extra * Math.random())");
1689
+ writer.line("current = try { reconnect.reopen(lastEventId) ?: return@flow } catch (error: java.io.IOException) { failure = error; null }");
1550
1690
  });
1551
1691
  });
1552
1692
  writer.blank();
1553
- writer.line("fun <Event : Any> events(response: Response, decode: (ServerSentEvent) -> Event?): Flow<Event> = serverSentEvents(response).mapNotNull(decode)");
1693
+ writer.line("fun <Event : Any> events(response: Response, reconnect: Reconnection? = null, decode: (ServerSentEvent) -> Event?): Flow<Event> = serverSentEvents(response, reconnect).mapNotNull(decode)");
1554
1694
  };
1555
1695
  const emitKizunaObject = (writer, options) => {
1556
1696
  writer.blank();
1557
1697
  writer.block("private object Kizuna", () => {
1558
- if (options.streaming) emitKizunaStreaming(writer);
1698
+ if (options.streaming) emitKizunaStreaming(writer, options.clientName);
1559
1699
  writer.block("fun resolveUrl(baseUrl: String, path: String): HttpUrl.Builder", () => {
1560
1700
  writer.line("val base = baseUrl.toHttpUrl()");
1561
1701
  writer.line("val trimmedPath = path.trimStart('/')");
@@ -1581,6 +1721,47 @@ const emitKizunaObject = (writer, options) => {
1581
1721
  writer.line("}");
1582
1722
  });
1583
1723
  writer.blank();
1724
+ writer.block(`suspend fun send(client: OkHttpClient, request: Request, retry: ${options.clientName}.RetryPolicy?): Response`, () => {
1725
+ writer.line("val idempotent = request.method in setOf(\"GET\", \"HEAD\", \"OPTIONS\", \"PUT\", \"DELETE\")");
1726
+ writer.line("val policy = retry?.takeIf { idempotent && request.body?.isOneShot() != true }");
1727
+ writer.line("var attempt = 1");
1728
+ writer.block("while (true)", () => {
1729
+ writer.line("val response = try {");
1730
+ writer.line(" execute(client, request)");
1731
+ writer.line("} catch (error: java.io.IOException) {");
1732
+ writer.line(" val delay = policy?.let { retryDelay(it, attempt, null) } ?: throw error");
1733
+ writer.line(" kotlinx.coroutines.delay(delay)");
1734
+ writer.line(" attempt += 1");
1735
+ writer.line(" continue");
1736
+ writer.line("}");
1737
+ writer.line("val delay = policy?.let { retryDelay(it, attempt, response) } ?: return response");
1738
+ writer.line("response.close()");
1739
+ writer.line("kotlinx.coroutines.delay(delay)");
1740
+ writer.line("attempt += 1");
1741
+ });
1742
+ });
1743
+ writer.blank();
1744
+ writer.block(`fun retryDelay(policy: ${options.clientName}.RetryPolicy, attempt: Int, response: Response?): Long?`, () => {
1745
+ writer.line("if (attempt >= policy.maximumAttempts) return null");
1746
+ writer.line("val ceiling = minOf(policy.maximumDelay.inWholeMilliseconds, policy.initialDelay.inWholeMilliseconds shl (attempt - 1))");
1747
+ writer.line("val backoff = (Math.random() * ceiling).toLong()");
1748
+ writer.line("if (response == null) return backoff");
1749
+ writer.line("if (response.code != 429 && response.code != 503) return null");
1750
+ writer.line("val asked = retryAfter(response.header(\"Retry-After\")) ?: return backoff");
1751
+ writer.line("return if (asked <= policy.maximumDelay.inWholeMilliseconds) asked else null");
1752
+ });
1753
+ writer.blank();
1754
+ writer.block("fun retryAfter(header: String?): Long?", () => {
1755
+ writer.line("val value = header?.trim() ?: return null");
1756
+ writer.line("value.toLongOrNull()?.let { return if (it >= 0) it * 1000 else null }");
1757
+ writer.line("val date = try {");
1758
+ writer.line(" java.time.ZonedDateTime.parse(value, java.time.format.DateTimeFormatter.RFC_1123_DATE_TIME)");
1759
+ writer.line("} catch (_: java.time.format.DateTimeParseException) {");
1760
+ writer.line(" return null");
1761
+ writer.line("}");
1762
+ writer.line("return maxOf(0L, date.toInstant().toEpochMilli() - System.currentTimeMillis())");
1763
+ });
1764
+ writer.blank();
1584
1765
  writer.block("suspend fun execute(client: OkHttpClient, request: Request): Response", () => {
1585
1766
  writer.line("return kotlinx.coroutines.suspendCancellableCoroutine { continuation ->");
1586
1767
  writer.indent(() => {
@@ -1608,20 +1789,48 @@ const emitClient = (writer, config, partition, context, typesByOperation, regist
1608
1789
  const allMethods = [...flatMethods, ...groups.flatMap((group) => group.methods)];
1609
1790
  const usesMultipart = allMethods.some((method) => method.body?.kind === "multipart");
1610
1791
  const contextFields = context.requestContextFields;
1611
- const contextRequired = contextFields.some((field) => !field.optional);
1792
+ const contextRequired = contextFields.some((field) => !hasDefault(field));
1612
1793
  const contextParam = contextFields.length > 0 ? `requestContext: RequestContext${contextRequired ? "" : " = RequestContext()"}, ` : "";
1613
1794
  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)`, () => {
1795
+ 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, private val reconnect: ReconnectPolicy? = null)`, () => {
1615
1796
  if (contextFields.length > 0) {
1616
1797
  writer.blank();
1617
1798
  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" : ""}`));
1799
+ emitConstructorClass(writer, "data class RequestContext", contextFields.map((field) => `val ${escapeKeyword(field.name)}: ${field.type}${defaultArgument(field)}`));
1619
1800
  writer.blank();
1620
1801
  writer.block("private val requestContextHeaders: Map<String, String> = buildMap", () => {
1621
1802
  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
1803
  else writer.line(`put(${stringLiteral(field.wireName)}, requestContext.${escapeKeyword(field.name)})`);
1623
1804
  });
1624
1805
  }
1806
+ writer.blank();
1807
+ writer.line("/**");
1808
+ writer.line(" * Retries a GET, HEAD, OPTIONS, PUT or DELETE that failed on the network or was answered 429 or 503,");
1809
+ writer.line(" * waiting what Retry-After asks or backing off.");
1810
+ writer.line(" *");
1811
+ writer.line(" * @property maximumAttempts Attempts in all, the first included.");
1812
+ writer.line(" * @property initialDelay What the first backoff waits at most.");
1813
+ writer.line(" * @property maximumDelay What any wait may take.");
1814
+ writer.line(" */");
1815
+ emitConstructorClass(writer, "data class RetryPolicy", [
1816
+ "val maximumAttempts: Int = 3",
1817
+ "val initialDelay: kotlin.time.Duration = 500.milliseconds",
1818
+ "val maximumDelay: kotlin.time.Duration = 30.seconds"
1819
+ ]);
1820
+ writer.blank();
1821
+ writer.line("/**");
1822
+ writer.line(" * Reconnects an event stream on a GET, HEAD, OPTIONS, PUT or DELETE when its connection drops,");
1823
+ writer.line(" * sending the id of the last event as Last-Event-ID.");
1824
+ writer.line(" *");
1825
+ writer.line(" * @property maximumAttempts Reconnections in a row before the stream fails. An event received resets the count.");
1826
+ writer.line(" * @property reconnectionTime What to wait before reconnecting, until the server sends a retry field.");
1827
+ writer.line(" * @property maximumDelay What the wait may grow to after reconnections in a row that fail.");
1828
+ writer.line(" */");
1829
+ emitConstructorClass(writer, "data class ReconnectPolicy", [
1830
+ "val maximumAttempts: Int = 5",
1831
+ "val reconnectionTime: kotlin.time.Duration = 3.seconds",
1832
+ "val maximumDelay: kotlin.time.Duration = 30.seconds"
1833
+ ]);
1625
1834
  if (usesMultipart) {
1626
1835
  writer.blank();
1627
1836
  emitConstructorClass(writer, "data class MultipartFile", [
@@ -1677,7 +1886,7 @@ const emitClient = (writer, config, partition, context, typesByOperation, regist
1677
1886
  }
1678
1887
  if (groups.length > 0) for (const group of groups) {
1679
1888
  writer.blank();
1680
- writer.line(`val ${escapeKeyword(group.propertyName)} = ${group.className}(client, baseUrl, json, ${contextFields.length > 0 ? "requestContextHeaders" : "emptyMap()"}, requestInterceptor, responseInterceptor)`);
1889
+ writer.line(`val ${escapeKeyword(group.propertyName)} = ${group.className}(client, baseUrl, json, ${contextFields.length > 0 ? "requestContextHeaders" : "emptyMap()"}, requestInterceptor, responseInterceptor, retry, reconnect)`);
1681
1890
  }
1682
1891
  for (const method of flatMethods) {
1683
1892
  writer.blank();
@@ -1685,21 +1894,8 @@ const emitClient = (writer, config, partition, context, typesByOperation, regist
1685
1894
  }
1686
1895
  });
1687
1896
  };
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
- });
1897
+ const renderKotlinClient = (api, partition, registry, config) => {
1898
+ const { namespaceName, packageName, camelCaseProperties = false } = config;
1703
1899
  const allMethods = [...partition.flatMethods, ...partition.groups.flatMap((group) => group.methods)];
1704
1900
  const operationTypeMap = buildOperationTypeMap(allMethods, registry);
1705
1901
  const clientName = `${namespaceName}Client`;
@@ -1735,6 +1931,8 @@ const generateKotlinClient = (api, config) => {
1735
1931
  writer.line("import okhttp3.*");
1736
1932
  writer.line("import okhttp3.MediaType.Companion.toMediaType");
1737
1933
  writer.line("import okhttp3.RequestBody.Companion.toRequestBody");
1934
+ writer.line("import kotlin.time.Duration.Companion.milliseconds");
1935
+ writer.line("import kotlin.time.Duration.Companion.seconds");
1738
1936
  writer.line("import okhttp3.HttpUrl.Companion.toHttpUrl");
1739
1937
  writer.blank();
1740
1938
  if (registry.all().some((type) => type.kind === "enum-class")) {
@@ -1784,13 +1982,43 @@ const generateKotlinClient = (api, config) => {
1784
1982
  };
1785
1983
  writer.block(`object ${namespaceName}`, () => {
1786
1984
  emitTypes(writer, topLevelSharedTypes, ownedTypeMap, ownedTypeLookup, registry);
1985
+ for (const brand of registry.allBrands()) {
1986
+ writer.blank();
1987
+ emitBrand(writer, brand);
1988
+ }
1787
1989
  });
1788
1990
  emitClient(writer, { clientName }, partition, context, typesByOperation, registry);
1789
1991
  for (const group of partition.groups) emitSubClientClass(writer, group, clientName, context);
1790
- emitKizunaObject(writer, { streaming: usesStreaming });
1992
+ emitKizunaObject(writer, {
1993
+ streaming: usesStreaming,
1994
+ clientName
1995
+ });
1791
1996
  for (const warning of registry.warnings()) process.stderr.write(`[kizuna/kotlin] JsonElement fallback at ${warning}\n`);
1792
1997
  return writer.toString();
1793
1998
  };
1999
+ /**
2000
+ * The Kotlin client's walk: the routes into a partition, then the file.
2001
+ */
2002
+ const kotlinClientWalk = (api, config) => {
2003
+ const registry = new TypeRegistry(config.camelCaseProperties ?? false, config.unknownEnumCase ?? false);
2004
+ const routes = kotlinRoutesWalk({
2005
+ namespaceName: config.namespaceName,
2006
+ registry
2007
+ });
2008
+ return {
2009
+ processRoute: routes.processRoute,
2010
+ finalize: () => renderKotlinClient(api, routes.finalize(), registry, config)
2011
+ };
2012
+ };
2013
+ /**
2014
+ * Generate a Kotlin API client from a Kizuna api.
2015
+ *
2016
+ * @param api - What `defineConfig` assembled.
2017
+ * @param config - Override the generated names:
2018
+ * - `namespaceName`: the object wrapping shared types.
2019
+ * - `packageName`: optional package declaration for the generated file.
2020
+ */
2021
+ const generateKotlinClient = (api, config) => (0, kizunajs_generator.walkApi)(api, kotlinClientWalk(api, config), { skipHidden: true });
1794
2022
  //#endregion
1795
2023
  Object.defineProperty(exports, "generateKotlinClient", {
1796
2024
  enumerable: true,
@@ -1798,3 +2026,9 @@ Object.defineProperty(exports, "generateKotlinClient", {
1798
2026
  return generateKotlinClient;
1799
2027
  }
1800
2028
  });
2029
+ Object.defineProperty(exports, "kotlinClientWalk", {
2030
+ enumerable: true,
2031
+ get: function() {
2032
+ return kotlinClientWalk;
2033
+ }
2034
+ });