@kizunajs/kotlin 2.0.0-beta.17 → 2.0.0-beta.18

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/dist/cli.cjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- const require_generator = require("./generator-D2dgwOZe.cjs");
2
+ const require_generator = require("./generator-CeUS3C_6.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-BYxosxwy.mjs";
2
+ import { t as generateKotlinClient } from "./generator-D2GCSZwz.mjs";
3
3
  import { mkdirSync, writeFileSync } from "node:fs";
4
4
  import { dirname, resolve } from "node:path";
5
5
  import { parseArgs } from "node:util";
@@ -58,6 +58,7 @@ var TypeRegistry = class {
58
58
  sealedVariantPaths = /* @__PURE__ */ new Map();
59
59
  brands = /* @__PURE__ */ new Map();
60
60
  usesJsonElement = false;
61
+ usesNullish = false;
61
62
  constructor(camelCaseProperties = false, unknownEnumCase = false) {
62
63
  this.camelCaseProperties = camelCaseProperties;
63
64
  this.unknownEnumCase = unknownEnumCase;
@@ -127,11 +128,13 @@ const propertyName = (key, camelCase) => {
127
128
  if (!camel) return "field";
128
129
  return /^[0-9]/.test(camel) ? `_${camel}` : camel;
129
130
  };
130
- const objectFields = (schema, registry, hint, shape = (0, kizunajs_generator.readObjectShape)(schema) ?? {}) => {
131
+ const objectFields = (schema, registry, hint, nullishTriState = true, shape = (0, kizunajs_generator.readObjectShape)(schema) ?? {}) => {
131
132
  const fields = [];
132
133
  const seen = /* @__PURE__ */ new Map();
133
134
  for (const [key, value] of Object.entries(shape)) {
134
135
  const result = mapType(value, registry, `${hint}${(0, kizunajs_generator.toPascalCase)(key)}`);
136
+ const nullish = nullishTriState && result.nullish === true;
137
+ if (nullish) registry.usesNullish = true;
135
138
  const deprecation = (0, kizunajs_generator.readDeprecation)(value);
136
139
  const kotlinName = propertyName(key, registry.camelCaseProperties);
137
140
  const previousWireName = seen.get(kotlinName);
@@ -140,8 +143,10 @@ const objectFields = (schema, registry, hint, shape = (0, kizunajs_generator.rea
140
143
  fields.push({
141
144
  name: kotlinName,
142
145
  wireName: key,
143
- type: result.expression,
144
- optional: result.optional,
146
+ type: nullish ? `Nullish<${result.expression.replace(/\?$/, "")}>` : result.expression,
147
+ optional: result.optional && !nullish,
148
+ requiredNullable: result.requiredNullable,
149
+ nullish,
145
150
  description: (0, kizunajs_generator.readMetaDescription)(value),
146
151
  isFile: result.isFile,
147
152
  deprecated: deprecation !== void 0,
@@ -264,6 +269,8 @@ const mapUnbrandedType = (schema, registry, hint) => {
264
269
  return {
265
270
  expression: innerResult.expression.endsWith("?") ? innerResult.expression : `${innerResult.expression}?`,
266
271
  optional: true,
272
+ requiredNullable: nullable && !optional,
273
+ nullish: nullable && optional,
267
274
  isFile: innerResult.isFile
268
275
  };
269
276
  }
@@ -445,9 +452,13 @@ const sanitizeCaseName = (literal) => {
445
452
  if (/^[0-9]/.test(cleaned)) return `_${cleaned}`;
446
453
  return cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
447
454
  };
448
- const collectObjectFields = (schema, registry, hint) => {
449
- return objectFields(schema, registry, hint);
455
+ /**
456
+ * A JSON body keeps `.nullish()` as `Nullish`. A query, path, header or form field has no null, so it takes an optional.
457
+ */
458
+ const collectObjectFields = (schema, registry, hint, json = false) => {
459
+ return objectFields(schema, registry, hint, json);
450
460
  };
461
+ const hasDefault = (field) => field.optional && field.requiredNullable !== true || field.nullish === true;
451
462
  const objectFieldCount = (schema) => {
452
463
  return Object.keys((0, kizunajs_generator.readObjectShape)(schema) ?? {}).length;
453
464
  };
@@ -512,7 +523,7 @@ const buildRouteMethod = (routeKey, route, registry, deprecated, deprecationMess
512
523
  else bodyDescriptor = {
513
524
  kind: "json-flat",
514
525
  structName,
515
- flattened: collectObjectFields(route.body, registry, bodyHint),
526
+ flattened: collectObjectFields(route.body, registry, bodyHint, true),
516
527
  multipartFields: []
517
528
  };
518
529
  }
@@ -678,6 +689,49 @@ const KOTLIN_KEYWORDS = new Set([
678
689
  const escapeKeyword = (name) => {
679
690
  return KOTLIN_KEYWORDS.has(name) ? `\`${name}\`` : name;
680
691
  };
692
+ const NULLISH_TYPE = /^Nullish<(.+)>$/;
693
+ const emitNullish = (writer) => {
694
+ writer.line("/** A `.nullish()` field: left out, sent as `null`, or sent with a value. */");
695
+ writer.line("@Serializable(with = NullishSerializer::class)");
696
+ writer.block("sealed interface Nullish<out T>", () => {
697
+ writer.line("val value: T?");
698
+ writer.blank();
699
+ writer.block("data object Absent : Nullish<Nothing>", () => {
700
+ writer.line("override val value: Nothing? get() = null");
701
+ });
702
+ writer.blank();
703
+ writer.block("data object Null : Nullish<Nothing>", () => {
704
+ writer.line("override val value: Nothing? get() = null");
705
+ });
706
+ writer.blank();
707
+ writer.line("data class Some<out T>(override val value: T) : Nullish<T>");
708
+ });
709
+ writer.blank();
710
+ writer.block("class NullishSerializer<T>(private val valueSerializer: KSerializer<T>) : KSerializer<Nullish<T>>", () => {
711
+ writer.line("override val descriptor: SerialDescriptor = valueSerializer.descriptor.nullable");
712
+ writer.blank();
713
+ writer.line("@OptIn(ExperimentalSerializationApi::class)");
714
+ writer.block("override fun serialize(encoder: Encoder, value: Nullish<T>)", () => {
715
+ writer.line("when (value) {");
716
+ writer.line(" is Nullish.Some -> encoder.encodeSerializableValue(valueSerializer, value.value)");
717
+ writer.line(" Nullish.Null -> encoder.encodeNull()");
718
+ writer.line(" Nullish.Absent -> throw SerializationException(\"An absent field has no value to write, so its key is left out\")");
719
+ writer.line("}");
720
+ });
721
+ writer.blank();
722
+ writer.line("@OptIn(ExperimentalSerializationApi::class)");
723
+ writer.block("override fun deserialize(decoder: Decoder): Nullish<T>", () => {
724
+ writer.line("if (decoder.decodeNotNullMark()) return Nullish.Some(decoder.decodeSerializableValue(valueSerializer))");
725
+ writer.line("decoder.decodeNull()");
726
+ writer.line("return Nullish.Null");
727
+ });
728
+ });
729
+ writer.blank();
730
+ };
731
+ const defaultArgument = (field) => {
732
+ if (!hasDefault(field)) return "";
733
+ return field.nullish ? " = Nullish.Absent" : " = null";
734
+ };
681
735
  const optionalize = (type, optional) => {
682
736
  if (!optional) return type;
683
737
  return type.endsWith("?") ? type : `${type}?`;
@@ -698,6 +752,8 @@ const KOTLIN_PRIMITIVE_TYPES = new Set([
698
752
  const decodeBody = (resolved) => resolved === "ByteArray" ? "data" : `json.decodeFromString<${resolved}>(data.decodeToString())`;
699
753
  const resolveType = (typeName, currentOperation, context, scope = "client") => {
700
754
  const { operationTypeMap, namespaceName, clientName, fileLevelTypeNames } = context;
755
+ const nullish = NULLISH_TYPE.exec(typeName);
756
+ if (nullish) return `Nullish<${resolveType(nullish[1], currentOperation, context, scope)}>`;
701
757
  const optional = typeName.endsWith("?");
702
758
  const base = optional ? typeName.slice(0, -1) : typeName;
703
759
  if (KOTLIN_PRIMITIVE_TYPES.has(base)) return typeName;
@@ -820,6 +876,8 @@ const emitDataClass = (writer, type, ownedTypeMap, ownedTypeLookup, registryName
820
876
  const baseFields = type.discriminatorWireName ? type.fields.filter((field) => field.wireName !== type.discriminatorWireName) : type.fields;
821
877
  const hasFile = baseFields.some((field) => field.isFile);
822
878
  const resolveOwnedType = (raw) => {
879
+ const nullish = NULLISH_TYPE.exec(raw);
880
+ if (nullish) return `Nullish<${resolveOwnedType(nullish[1])}>`;
823
881
  const optional = raw.endsWith("?");
824
882
  const stripped = optional ? raw.slice(0, -1) : raw;
825
883
  const isList = stripped.startsWith("List<") && stripped.endsWith(">");
@@ -835,12 +893,15 @@ const emitDataClass = (writer, type, ownedTypeMap, ownedTypeLookup, registryName
835
893
  }));
836
894
  const needsSerialName = !hasFile && baseFields.some((field) => field.name !== field.wireName || KOTLIN_KEYWORDS.has(field.name));
837
895
  if (type.serialName !== void 0) writer.line(`@SerialName(${stringLiteral(type.serialName)})`);
838
- if (!hasFile) writer.line("@Serializable");
896
+ if (!hasFile) {
897
+ if (adjustedFields.some((field) => field.nullish)) writer.line("@OptIn(ExperimentalSerializationApi::class)");
898
+ writer.line("@Serializable");
899
+ }
839
900
  const params = adjustedFields.map((field) => {
840
901
  const typeExpression = optionalize(field.type, field.optional);
841
- const defaultPart = field.optional ? " = null" : "";
902
+ const defaultPart = defaultArgument(field);
842
903
  const serialNameAnnotation = needsSerialName && (field.name !== field.wireName || KOTLIN_KEYWORDS.has(field.name)) ? `@SerialName(${stringLiteral(field.wireName)}) ` : "";
843
- return `${field.deprecated ? `${deprecatedAnnotation(field.deprecationMessage)} ` : ""}${serialNameAnnotation}val ${escapeKeyword(field.name)}: ${typeExpression}${defaultPart}`;
904
+ return `${field.deprecated ? `${deprecatedAnnotation(field.deprecationMessage)} ` : ""}${serialNameAnnotation}${field.nullish ? "@EncodeDefault(EncodeDefault.Mode.NEVER) " : ""}val ${escapeKeyword(field.name)}: ${typeExpression}${defaultPart}`;
844
905
  });
845
906
  const hasOwnedTypes = Array.from(ownedTypeMap.entries()).some(([, owner]) => owner === lookupName);
846
907
  if (params.length === 0) writer.line(`data class ${type.name}(private val unused: Unit = Unit)`);
@@ -882,6 +943,8 @@ const relativeOwnedPath = (typeName, ownedTypeMap, enclosingPath, registry) => {
882
943
  return path.startsWith(prefix) ? path.slice(prefix.length) : void 0;
883
944
  };
884
945
  const ownedTypePath = (typeName, ownedTypeMap, registry) => {
946
+ const nullish = NULLISH_TYPE.exec(typeName);
947
+ if (nullish) return `Nullish<${ownedTypePath(nullish[1], ownedTypeMap, registry)}>`;
885
948
  const sealedPath = registry?.sealedVariantPath(typeName);
886
949
  if (sealedPath !== void 0) return sealedPath;
887
950
  const owningClass = ownedTypeMap.get(typeName);
@@ -931,7 +994,7 @@ const emitSealedClass = (writer, type, registry, ownedTypeMap, ownedTypeLookup)
931
994
  const params = fields.map((field) => {
932
995
  const variantPath = registry.sealedVariantPath(variant.payloadType) ?? variant.payloadType;
933
996
  const typeExpression = optionalize(relativeOwnedPath(field.type, ownedTypeMap, variantPath, registry) ?? ownedTypePath(field.type, ownedTypeMap, registry), field.optional);
934
- const defaultPart = field.optional ? " = null" : "";
997
+ const defaultPart = defaultArgument(field);
935
998
  return `val ${escapeKeyword(field.name)}: ${typeExpression}${defaultPart}`;
936
999
  });
937
1000
  if (params.length === 1) writer.line(`data class ${variant.caseName}(${params[0]}) : ${type.name}`);
@@ -1130,7 +1193,7 @@ const groupTypeRef = (operationName, className, context) => `${context.clientNam
1130
1193
  const groupMemberAccessor = (groupLabel, field) => `${groupLabel}.${escapeKeyword(field.name)}`;
1131
1194
  const groupClassField = (field) => {
1132
1195
  const typeExpression = optionalize(field.type, field.optional);
1133
- const defaultPart = field.optional ? " = null" : "";
1196
+ const defaultPart = defaultArgument(field);
1134
1197
  return `val ${escapeKeyword(field.name)}: ${typeExpression}${defaultPart}`;
1135
1198
  };
1136
1199
  const emitGroupClass = (writer, className, fields, method, context) => {
@@ -1189,13 +1252,13 @@ const methodChannels = (method) => {
1189
1252
  if (method.query.length > 0) channels.push({
1190
1253
  varName: "query",
1191
1254
  className: "Query",
1192
- required: method.query.some((field) => !field.optional),
1255
+ required: method.query.some((field) => !hasDefault(field)),
1193
1256
  fields: method.query
1194
1257
  });
1195
1258
  if (method.headers.length > 0) channels.push({
1196
1259
  varName: "headers",
1197
1260
  className: "Headers",
1198
- required: method.headers.some((field) => !field.optional),
1261
+ required: method.headers.some((field) => !hasDefault(field)),
1199
1262
  fields: method.headers
1200
1263
  });
1201
1264
  if (hasBodyGroup(method)) {
@@ -1244,7 +1307,7 @@ const emitArgsScaffolding = (writer, method, context) => {
1244
1307
  };
1245
1308
  const factorySignature = (channel) => channel.fields.map((field) => {
1246
1309
  const typeExpression = optionalize(field.type, field.optional);
1247
- const defaultPart = field.optional ? " = null" : "";
1310
+ const defaultPart = defaultArgument(field);
1248
1311
  return `${escapeKeyword(field.name)}: ${typeExpression}${defaultPart}`;
1249
1312
  }).join(", ");
1250
1313
  const factoryConstruction = (channel) => {
@@ -1301,10 +1364,16 @@ const emitBodyEncoding = (writer, method, context) => {
1301
1364
  if (body.kind === "multipart") {
1302
1365
  writer.line("val multipartBuilder = MultipartBody.Builder().setType(MultipartBody.FORM)");
1303
1366
  for (const field of body.multipartFields) {
1304
- const accessor = groupMemberAccessor("body", field);
1305
- const isFlattenedFile = body.flattened.find((flatField) => flatField.name === field.name)?.isFile === true;
1306
- if (field.isFile || isFlattenedFile) writer.line(`multipartBuilder.addFormDataPart(${stringLiteral(field.wireName)}, ${accessor}.filename, ${accessor}.data.toRequestBody(${accessor}.mimeType.toMediaType()))`);
1307
- else writer.line(`multipartBuilder.addFormDataPart(${stringLiteral(field.wireName)}, ${accessor}.toString())`);
1367
+ const flattenedField = body.flattened.find((flatField) => flatField.name === field.name);
1368
+ const isFile = field.isFile || flattenedField?.isFile === true;
1369
+ const append = (accessor) => {
1370
+ if (isFile) writer.line(`multipartBuilder.addFormDataPart(${stringLiteral(field.wireName)}, ${accessor}.filename, ${accessor}.data.toRequestBody(${accessor}.mimeType.toMediaType()))`);
1371
+ else writer.line(`multipartBuilder.addFormDataPart(${stringLiteral(field.wireName)}, ${accessor}.toString())`);
1372
+ };
1373
+ if (flattenedField?.optional === true) writer.block(`if (${groupMemberAccessor("body", field)} != null)`, () => {
1374
+ append(groupMemberAccessor("body", field));
1375
+ });
1376
+ else append(groupMemberAccessor("body", field));
1308
1377
  }
1309
1378
  writer.line("requestBody = multipartBuilder.build()");
1310
1379
  return;
@@ -1664,14 +1733,14 @@ const emitClient = (writer, config, partition, context, typesByOperation, regist
1664
1733
  const allMethods = [...flatMethods, ...groups.flatMap((group) => group.methods)];
1665
1734
  const usesMultipart = allMethods.some((method) => method.body?.kind === "multipart");
1666
1735
  const contextFields = context.requestContextFields;
1667
- const contextRequired = contextFields.some((field) => !field.optional);
1736
+ const contextRequired = contextFields.some((field) => !hasDefault(field));
1668
1737
  const contextParam = contextFields.length > 0 ? `requestContext: RequestContext${contextRequired ? "" : " = RequestContext()"}, ` : "";
1669
1738
  writer.blank();
1670
1739
  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)`, () => {
1671
1740
  if (contextFields.length > 0) {
1672
1741
  writer.blank();
1673
1742
  writer.line("/** Values sent as headers on every request, from the api's request context. */");
1674
- emitConstructorClass(writer, "data class RequestContext", contextFields.map((field) => `val ${escapeKeyword(field.name)}: ${field.type}${field.optional ? " = null" : ""}`));
1743
+ emitConstructorClass(writer, "data class RequestContext", contextFields.map((field) => `val ${escapeKeyword(field.name)}: ${field.type}${defaultArgument(field)}`));
1675
1744
  writer.blank();
1676
1745
  writer.block("private val requestContextHeaders: Map<String, String> = buildMap", () => {
1677
1746
  for (const field of contextFields) if (field.optional) writer.line(`if (requestContext.${escapeKeyword(field.name)} != null) put(${stringLiteral(field.wireName)}, requestContext.${escapeKeyword(field.name)}!!)`);
@@ -1768,7 +1837,7 @@ const renderKotlinClient = (api, partition, registry, config) => {
1768
1837
  }
1769
1838
  writer.line("import kotlinx.serialization.*");
1770
1839
  writer.line("import kotlinx.serialization.json.*");
1771
- if (registry.all().some((type) => type.kind === "enum-class" && type.unknownCase)) {
1840
+ if (registry.all().some((type) => type.kind === "enum-class" && type.unknownCase) || registry.usesNullish) {
1772
1841
  writer.line("import kotlinx.serialization.descriptors.*");
1773
1842
  writer.line("import kotlinx.serialization.encoding.*");
1774
1843
  }
@@ -1786,6 +1855,7 @@ const renderKotlinClient = (api, partition, registry, config) => {
1786
1855
  });
1787
1856
  writer.blank();
1788
1857
  }
1858
+ if (registry.usesNullish) emitNullish(writer);
1789
1859
  const sharedTypes = [];
1790
1860
  const typesByOperation = /* @__PURE__ */ new Map();
1791
1861
  for (const type of registry.all()) {
@@ -58,6 +58,7 @@ var TypeRegistry = class {
58
58
  sealedVariantPaths = /* @__PURE__ */ new Map();
59
59
  brands = /* @__PURE__ */ new Map();
60
60
  usesJsonElement = false;
61
+ usesNullish = false;
61
62
  constructor(camelCaseProperties = false, unknownEnumCase = false) {
62
63
  this.camelCaseProperties = camelCaseProperties;
63
64
  this.unknownEnumCase = unknownEnumCase;
@@ -127,11 +128,13 @@ const propertyName = (key, camelCase) => {
127
128
  if (!camel) return "field";
128
129
  return /^[0-9]/.test(camel) ? `_${camel}` : camel;
129
130
  };
130
- const objectFields = (schema, registry, hint, shape = readObjectShape(schema) ?? {}) => {
131
+ const objectFields = (schema, registry, hint, nullishTriState = true, shape = readObjectShape(schema) ?? {}) => {
131
132
  const fields = [];
132
133
  const seen = /* @__PURE__ */ new Map();
133
134
  for (const [key, value] of Object.entries(shape)) {
134
135
  const result = mapType(value, registry, `${hint}${toPascalCase(key)}`);
136
+ const nullish = nullishTriState && result.nullish === true;
137
+ if (nullish) registry.usesNullish = true;
135
138
  const deprecation = readDeprecation(value);
136
139
  const kotlinName = propertyName(key, registry.camelCaseProperties);
137
140
  const previousWireName = seen.get(kotlinName);
@@ -140,8 +143,10 @@ const objectFields = (schema, registry, hint, shape = readObjectShape(schema) ??
140
143
  fields.push({
141
144
  name: kotlinName,
142
145
  wireName: key,
143
- type: result.expression,
144
- optional: result.optional,
146
+ type: nullish ? `Nullish<${result.expression.replace(/\?$/, "")}>` : result.expression,
147
+ optional: result.optional && !nullish,
148
+ requiredNullable: result.requiredNullable,
149
+ nullish,
145
150
  description: readMetaDescription(value),
146
151
  isFile: result.isFile,
147
152
  deprecated: deprecation !== void 0,
@@ -264,6 +269,8 @@ const mapUnbrandedType = (schema, registry, hint) => {
264
269
  return {
265
270
  expression: innerResult.expression.endsWith("?") ? innerResult.expression : `${innerResult.expression}?`,
266
271
  optional: true,
272
+ requiredNullable: nullable && !optional,
273
+ nullish: nullable && optional,
267
274
  isFile: innerResult.isFile
268
275
  };
269
276
  }
@@ -445,9 +452,13 @@ const sanitizeCaseName = (literal) => {
445
452
  if (/^[0-9]/.test(cleaned)) return `_${cleaned}`;
446
453
  return cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
447
454
  };
448
- const collectObjectFields = (schema, registry, hint) => {
449
- return objectFields(schema, registry, hint);
455
+ /**
456
+ * A JSON body keeps `.nullish()` as `Nullish`. A query, path, header or form field has no null, so it takes an optional.
457
+ */
458
+ const collectObjectFields = (schema, registry, hint, json = false) => {
459
+ return objectFields(schema, registry, hint, json);
450
460
  };
461
+ const hasDefault = (field) => field.optional && field.requiredNullable !== true || field.nullish === true;
451
462
  const objectFieldCount = (schema) => {
452
463
  return Object.keys(readObjectShape(schema) ?? {}).length;
453
464
  };
@@ -512,7 +523,7 @@ const buildRouteMethod = (routeKey, route, registry, deprecated, deprecationMess
512
523
  else bodyDescriptor = {
513
524
  kind: "json-flat",
514
525
  structName,
515
- flattened: collectObjectFields(route.body, registry, bodyHint),
526
+ flattened: collectObjectFields(route.body, registry, bodyHint, true),
516
527
  multipartFields: []
517
528
  };
518
529
  }
@@ -678,6 +689,49 @@ const KOTLIN_KEYWORDS = new Set([
678
689
  const escapeKeyword = (name) => {
679
690
  return KOTLIN_KEYWORDS.has(name) ? `\`${name}\`` : name;
680
691
  };
692
+ const NULLISH_TYPE = /^Nullish<(.+)>$/;
693
+ const emitNullish = (writer) => {
694
+ writer.line("/** A `.nullish()` field: left out, sent as `null`, or sent with a value. */");
695
+ writer.line("@Serializable(with = NullishSerializer::class)");
696
+ writer.block("sealed interface Nullish<out T>", () => {
697
+ writer.line("val value: T?");
698
+ writer.blank();
699
+ writer.block("data object Absent : Nullish<Nothing>", () => {
700
+ writer.line("override val value: Nothing? get() = null");
701
+ });
702
+ writer.blank();
703
+ writer.block("data object Null : Nullish<Nothing>", () => {
704
+ writer.line("override val value: Nothing? get() = null");
705
+ });
706
+ writer.blank();
707
+ writer.line("data class Some<out T>(override val value: T) : Nullish<T>");
708
+ });
709
+ writer.blank();
710
+ writer.block("class NullishSerializer<T>(private val valueSerializer: KSerializer<T>) : KSerializer<Nullish<T>>", () => {
711
+ writer.line("override val descriptor: SerialDescriptor = valueSerializer.descriptor.nullable");
712
+ writer.blank();
713
+ writer.line("@OptIn(ExperimentalSerializationApi::class)");
714
+ writer.block("override fun serialize(encoder: Encoder, value: Nullish<T>)", () => {
715
+ writer.line("when (value) {");
716
+ writer.line(" is Nullish.Some -> encoder.encodeSerializableValue(valueSerializer, value.value)");
717
+ writer.line(" Nullish.Null -> encoder.encodeNull()");
718
+ writer.line(" Nullish.Absent -> throw SerializationException(\"An absent field has no value to write, so its key is left out\")");
719
+ writer.line("}");
720
+ });
721
+ writer.blank();
722
+ writer.line("@OptIn(ExperimentalSerializationApi::class)");
723
+ writer.block("override fun deserialize(decoder: Decoder): Nullish<T>", () => {
724
+ writer.line("if (decoder.decodeNotNullMark()) return Nullish.Some(decoder.decodeSerializableValue(valueSerializer))");
725
+ writer.line("decoder.decodeNull()");
726
+ writer.line("return Nullish.Null");
727
+ });
728
+ });
729
+ writer.blank();
730
+ };
731
+ const defaultArgument = (field) => {
732
+ if (!hasDefault(field)) return "";
733
+ return field.nullish ? " = Nullish.Absent" : " = null";
734
+ };
681
735
  const optionalize = (type, optional) => {
682
736
  if (!optional) return type;
683
737
  return type.endsWith("?") ? type : `${type}?`;
@@ -698,6 +752,8 @@ const KOTLIN_PRIMITIVE_TYPES = new Set([
698
752
  const decodeBody = (resolved) => resolved === "ByteArray" ? "data" : `json.decodeFromString<${resolved}>(data.decodeToString())`;
699
753
  const resolveType = (typeName, currentOperation, context, scope = "client") => {
700
754
  const { operationTypeMap, namespaceName, clientName, fileLevelTypeNames } = context;
755
+ const nullish = NULLISH_TYPE.exec(typeName);
756
+ if (nullish) return `Nullish<${resolveType(nullish[1], currentOperation, context, scope)}>`;
701
757
  const optional = typeName.endsWith("?");
702
758
  const base = optional ? typeName.slice(0, -1) : typeName;
703
759
  if (KOTLIN_PRIMITIVE_TYPES.has(base)) return typeName;
@@ -820,6 +876,8 @@ const emitDataClass = (writer, type, ownedTypeMap, ownedTypeLookup, registryName
820
876
  const baseFields = type.discriminatorWireName ? type.fields.filter((field) => field.wireName !== type.discriminatorWireName) : type.fields;
821
877
  const hasFile = baseFields.some((field) => field.isFile);
822
878
  const resolveOwnedType = (raw) => {
879
+ const nullish = NULLISH_TYPE.exec(raw);
880
+ if (nullish) return `Nullish<${resolveOwnedType(nullish[1])}>`;
823
881
  const optional = raw.endsWith("?");
824
882
  const stripped = optional ? raw.slice(0, -1) : raw;
825
883
  const isList = stripped.startsWith("List<") && stripped.endsWith(">");
@@ -835,12 +893,15 @@ const emitDataClass = (writer, type, ownedTypeMap, ownedTypeLookup, registryName
835
893
  }));
836
894
  const needsSerialName = !hasFile && baseFields.some((field) => field.name !== field.wireName || KOTLIN_KEYWORDS.has(field.name));
837
895
  if (type.serialName !== void 0) writer.line(`@SerialName(${stringLiteral(type.serialName)})`);
838
- if (!hasFile) writer.line("@Serializable");
896
+ if (!hasFile) {
897
+ if (adjustedFields.some((field) => field.nullish)) writer.line("@OptIn(ExperimentalSerializationApi::class)");
898
+ writer.line("@Serializable");
899
+ }
839
900
  const params = adjustedFields.map((field) => {
840
901
  const typeExpression = optionalize(field.type, field.optional);
841
- const defaultPart = field.optional ? " = null" : "";
902
+ const defaultPart = defaultArgument(field);
842
903
  const serialNameAnnotation = needsSerialName && (field.name !== field.wireName || KOTLIN_KEYWORDS.has(field.name)) ? `@SerialName(${stringLiteral(field.wireName)}) ` : "";
843
- return `${field.deprecated ? `${deprecatedAnnotation(field.deprecationMessage)} ` : ""}${serialNameAnnotation}val ${escapeKeyword(field.name)}: ${typeExpression}${defaultPart}`;
904
+ return `${field.deprecated ? `${deprecatedAnnotation(field.deprecationMessage)} ` : ""}${serialNameAnnotation}${field.nullish ? "@EncodeDefault(EncodeDefault.Mode.NEVER) " : ""}val ${escapeKeyword(field.name)}: ${typeExpression}${defaultPart}`;
844
905
  });
845
906
  const hasOwnedTypes = Array.from(ownedTypeMap.entries()).some(([, owner]) => owner === lookupName);
846
907
  if (params.length === 0) writer.line(`data class ${type.name}(private val unused: Unit = Unit)`);
@@ -882,6 +943,8 @@ const relativeOwnedPath = (typeName, ownedTypeMap, enclosingPath, registry) => {
882
943
  return path.startsWith(prefix) ? path.slice(prefix.length) : void 0;
883
944
  };
884
945
  const ownedTypePath = (typeName, ownedTypeMap, registry) => {
946
+ const nullish = NULLISH_TYPE.exec(typeName);
947
+ if (nullish) return `Nullish<${ownedTypePath(nullish[1], ownedTypeMap, registry)}>`;
885
948
  const sealedPath = registry?.sealedVariantPath(typeName);
886
949
  if (sealedPath !== void 0) return sealedPath;
887
950
  const owningClass = ownedTypeMap.get(typeName);
@@ -931,7 +994,7 @@ const emitSealedClass = (writer, type, registry, ownedTypeMap, ownedTypeLookup)
931
994
  const params = fields.map((field) => {
932
995
  const variantPath = registry.sealedVariantPath(variant.payloadType) ?? variant.payloadType;
933
996
  const typeExpression = optionalize(relativeOwnedPath(field.type, ownedTypeMap, variantPath, registry) ?? ownedTypePath(field.type, ownedTypeMap, registry), field.optional);
934
- const defaultPart = field.optional ? " = null" : "";
997
+ const defaultPart = defaultArgument(field);
935
998
  return `val ${escapeKeyword(field.name)}: ${typeExpression}${defaultPart}`;
936
999
  });
937
1000
  if (params.length === 1) writer.line(`data class ${variant.caseName}(${params[0]}) : ${type.name}`);
@@ -1130,7 +1193,7 @@ const groupTypeRef = (operationName, className, context) => `${context.clientNam
1130
1193
  const groupMemberAccessor = (groupLabel, field) => `${groupLabel}.${escapeKeyword(field.name)}`;
1131
1194
  const groupClassField = (field) => {
1132
1195
  const typeExpression = optionalize(field.type, field.optional);
1133
- const defaultPart = field.optional ? " = null" : "";
1196
+ const defaultPart = defaultArgument(field);
1134
1197
  return `val ${escapeKeyword(field.name)}: ${typeExpression}${defaultPart}`;
1135
1198
  };
1136
1199
  const emitGroupClass = (writer, className, fields, method, context) => {
@@ -1189,13 +1252,13 @@ const methodChannels = (method) => {
1189
1252
  if (method.query.length > 0) channels.push({
1190
1253
  varName: "query",
1191
1254
  className: "Query",
1192
- required: method.query.some((field) => !field.optional),
1255
+ required: method.query.some((field) => !hasDefault(field)),
1193
1256
  fields: method.query
1194
1257
  });
1195
1258
  if (method.headers.length > 0) channels.push({
1196
1259
  varName: "headers",
1197
1260
  className: "Headers",
1198
- required: method.headers.some((field) => !field.optional),
1261
+ required: method.headers.some((field) => !hasDefault(field)),
1199
1262
  fields: method.headers
1200
1263
  });
1201
1264
  if (hasBodyGroup(method)) {
@@ -1244,7 +1307,7 @@ const emitArgsScaffolding = (writer, method, context) => {
1244
1307
  };
1245
1308
  const factorySignature = (channel) => channel.fields.map((field) => {
1246
1309
  const typeExpression = optionalize(field.type, field.optional);
1247
- const defaultPart = field.optional ? " = null" : "";
1310
+ const defaultPart = defaultArgument(field);
1248
1311
  return `${escapeKeyword(field.name)}: ${typeExpression}${defaultPart}`;
1249
1312
  }).join(", ");
1250
1313
  const factoryConstruction = (channel) => {
@@ -1301,10 +1364,16 @@ const emitBodyEncoding = (writer, method, context) => {
1301
1364
  if (body.kind === "multipart") {
1302
1365
  writer.line("val multipartBuilder = MultipartBody.Builder().setType(MultipartBody.FORM)");
1303
1366
  for (const field of body.multipartFields) {
1304
- const accessor = groupMemberAccessor("body", field);
1305
- const isFlattenedFile = body.flattened.find((flatField) => flatField.name === field.name)?.isFile === true;
1306
- if (field.isFile || isFlattenedFile) writer.line(`multipartBuilder.addFormDataPart(${stringLiteral(field.wireName)}, ${accessor}.filename, ${accessor}.data.toRequestBody(${accessor}.mimeType.toMediaType()))`);
1307
- else writer.line(`multipartBuilder.addFormDataPart(${stringLiteral(field.wireName)}, ${accessor}.toString())`);
1367
+ const flattenedField = body.flattened.find((flatField) => flatField.name === field.name);
1368
+ const isFile = field.isFile || flattenedField?.isFile === true;
1369
+ const append = (accessor) => {
1370
+ if (isFile) writer.line(`multipartBuilder.addFormDataPart(${stringLiteral(field.wireName)}, ${accessor}.filename, ${accessor}.data.toRequestBody(${accessor}.mimeType.toMediaType()))`);
1371
+ else writer.line(`multipartBuilder.addFormDataPart(${stringLiteral(field.wireName)}, ${accessor}.toString())`);
1372
+ };
1373
+ if (flattenedField?.optional === true) writer.block(`if (${groupMemberAccessor("body", field)} != null)`, () => {
1374
+ append(groupMemberAccessor("body", field));
1375
+ });
1376
+ else append(groupMemberAccessor("body", field));
1308
1377
  }
1309
1378
  writer.line("requestBody = multipartBuilder.build()");
1310
1379
  return;
@@ -1664,14 +1733,14 @@ const emitClient = (writer, config, partition, context, typesByOperation, regist
1664
1733
  const allMethods = [...flatMethods, ...groups.flatMap((group) => group.methods)];
1665
1734
  const usesMultipart = allMethods.some((method) => method.body?.kind === "multipart");
1666
1735
  const contextFields = context.requestContextFields;
1667
- const contextRequired = contextFields.some((field) => !field.optional);
1736
+ const contextRequired = contextFields.some((field) => !hasDefault(field));
1668
1737
  const contextParam = contextFields.length > 0 ? `requestContext: RequestContext${contextRequired ? "" : " = RequestContext()"}, ` : "";
1669
1738
  writer.blank();
1670
1739
  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)`, () => {
1671
1740
  if (contextFields.length > 0) {
1672
1741
  writer.blank();
1673
1742
  writer.line("/** Values sent as headers on every request, from the api's request context. */");
1674
- emitConstructorClass(writer, "data class RequestContext", contextFields.map((field) => `val ${escapeKeyword(field.name)}: ${field.type}${field.optional ? " = null" : ""}`));
1743
+ emitConstructorClass(writer, "data class RequestContext", contextFields.map((field) => `val ${escapeKeyword(field.name)}: ${field.type}${defaultArgument(field)}`));
1675
1744
  writer.blank();
1676
1745
  writer.block("private val requestContextHeaders: Map<String, String> = buildMap", () => {
1677
1746
  for (const field of contextFields) if (field.optional) writer.line(`if (requestContext.${escapeKeyword(field.name)} != null) put(${stringLiteral(field.wireName)}, requestContext.${escapeKeyword(field.name)}!!)`);
@@ -1768,7 +1837,7 @@ const renderKotlinClient = (api, partition, registry, config) => {
1768
1837
  }
1769
1838
  writer.line("import kotlinx.serialization.*");
1770
1839
  writer.line("import kotlinx.serialization.json.*");
1771
- if (registry.all().some((type) => type.kind === "enum-class" && type.unknownCase)) {
1840
+ if (registry.all().some((type) => type.kind === "enum-class" && type.unknownCase) || registry.usesNullish) {
1772
1841
  writer.line("import kotlinx.serialization.descriptors.*");
1773
1842
  writer.line("import kotlinx.serialization.encoding.*");
1774
1843
  }
@@ -1786,6 +1855,7 @@ const renderKotlinClient = (api, partition, registry, config) => {
1786
1855
  });
1787
1856
  writer.blank();
1788
1857
  }
1858
+ if (registry.usesNullish) emitNullish(writer);
1789
1859
  const sharedTypes = [];
1790
1860
  const typesByOperation = /* @__PURE__ */ new Map();
1791
1861
  for (const type of registry.all()) {
package/dist/index.cjs CHANGED
@@ -1,5 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_generator = require("./generator-D2dgwOZe.cjs");
2
+ const require_generator = require("./generator-CeUS3C_6.cjs");
3
3
  let kizunajs_generator = require("kizunajs/generator");
4
4
  let zod = require("zod");
5
5
  //#region src/client-target.ts
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { n as kotlinClientWalk, t as generateKotlinClient } from "./generator-BYxosxwy.mjs";
1
+ import { n as kotlinClientWalk, t as generateKotlinClient } from "./generator-D2GCSZwz.mjs";
2
2
  import { defineClient } from "kizunajs/generator";
3
3
  import { z } from "zod";
4
4
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kizunajs/kotlin",
3
- "version": "2.0.0-beta.17",
3
+ "version": "2.0.0-beta.18",
4
4
  "description": "Kotlin client generation for Kizuna.js APIs",
5
5
  "keywords": [
6
6
  "kizunajs",
@@ -60,14 +60,14 @@
60
60
  "access": "public"
61
61
  },
62
62
  "dependencies": {
63
- "@kizunajs/cli": "2.0.0-beta.17"
63
+ "@kizunajs/cli": "2.0.0-beta.18"
64
64
  },
65
65
  "peerDependencies": {
66
- "kizunajs": "2.0.0-beta.17",
66
+ "kizunajs": "2.0.0-beta.18",
67
67
  "zod": "^4.0.0"
68
68
  },
69
69
  "devDependencies": {
70
- "kizunajs": "2.0.0-beta.17",
70
+ "kizunajs": "2.0.0-beta.18",
71
71
  "@types/node": "^22.9.0",
72
72
  "tsdown": "^0.21.0",
73
73
  "typescript": "^6.0.3",