Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 30 additions & 2 deletions docs/src/kotlin_config.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ return Kotlin `null`, while nullable unions use `VariantNull`.
Kotlin `typealias` declarations remain transparent. Intersections retain their
existing wrapper fields (for example, `leftPart` and `rightPart`); this is not an
`allOf` codec redesign. Ordinary objects, aliases, enum declarations, and tagged
unions keep their existing APIs/codecs. Dictionary and additional-property repair
is outside this change.
unions keep their existing APIs/codecs. Pure dictionaries have the representation
described below; mixed-object additional-property repair remains separate.

Raw mode uses Gson's native tree adapter and temporarily enables writer null
serialization, restoring its prior value even on failure. This preserves null
Expand All @@ -47,3 +47,31 @@ The policy can also be set through the CLI:
openapi-nexus generate --input api.yaml --output sdk --generators kotlin-okhttp \
--generator-config kotlin-okhttp.untagged_union_strategy=strict
```

## Pure dictionary models

A named object with no named properties and an explicit `additionalProperties`
schema generates a data class with `additionalProperties: MutableMap<String, V>`
storage, defaulting to an empty map. Its Gson adapter reads and writes a flat JSON
object: the storage property's name is not added to the wire representation.

```kotlin
val labels = Labels()
labels.additionalProperties["keep_key"] = "value"
val json = Gson().toJson(labels) // {"keep_key":"value"}
```

Keys stay verbatim. Values retain their declared model, scalar, nullable or
container types; `additionalProperties: true` and `{}` use `JsonElement` values
to preserve arbitrary JSON, null entries and large numeric literals. Dictionary
aliases remain transparent and containing fields, lists, maps and unions use the
same adapter. JSON null at the root stays Kotlin null; non-object roots fail
instead of becoming empty dictionaries.

Dictionary entry nulls are preserved without changing the writer's surrounding
null policy. Values decode from the original JSON reader through their declared
Gson codecs, preserving native decode errors as well as ordinary model null-field
omission. Authored model types remain distinct from Gson helper types, even when
they share names such as `TypeToken` or `JsonElement`. This does not preserve
unspecified fields in mixed objects or redesign `allOf` wrappers; those repairs
remain separate.
177 changes: 177 additions & 0 deletions src/generators/kotlin/okhttp/dictionary.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
//! Named pure dictionaries retain their wire object shape and declared values.

use heck::ToPascalCase;
use sigil_stitch::lang::kotlin::Kotlin;
use sigil_stitch::prelude::*;

use crate::generators::type_application;
use crate::ir::types::{IrSchema, IrTypeExpr};

use super::sigil_emit::{kotlin_type_name_in, package_header};

pub(super) fn file(
schema: &IrSchema,
values: &IrTypeExpr,
package_name: &str,
) -> Result<FileSpec, SigilStitchError> {
let name = schema.name.to_pascal_case();
let models_package = format!("{package_name}.models");
let root = TypeName::qualified(&models_package, &name);
let value_type = kotlin_type_name_in(values, Some(&models_package));
let adapter_name = format!("{name}JsonAdapter");
let map = type_application(
TypeName::qualified("kotlin.collections", "MutableMap"),
vec![TypeName::qualified("kotlin", "String"), value_type.clone()],
);
// Gson's built-in map factory precedes @JsonAdapter, so a Map subtype would
// bypass this codec and drop null entries. Keep explicit dictionary storage.
let mut dictionary = TypeSpec::builder(&name, TypeKind::Struct)
.visibility(Visibility::Public)
.add_primary_constructor_param(
ParameterSpec::builder("additionalProperties", map)
.is_property()
.default_value(
sigil_quote!(Kotlin { linkedMapOf() })?.__sigil_trim_trailing_newline(),
)
.build()?,
)
.annotate(
AnnotationSpec::importable(TypeName::importable(
"com.google.gson.annotations",
"JsonAdapter",
))
.args([format!("{adapter_name}::class"), "nullSafe = false".into()]),
);
if let Some(doc) = &schema.description {
dictionary = dictionary.doc(doc);
}
let gson = TypeName::importable("com.google.gson", "Gson");
let element = TypeName::importable("com.google.gson", "JsonElement");
let exception = TypeName::importable("com.google.gson", "JsonParseException");
let json_token = TypeName::importable("com.google.gson.stream", "JsonToken");
let token = type_application(
TypeName::importable("com.google.gson.reflect", "TypeToken"),
vec![value_type.clone()],
);
// Delegate from the original reader: Gson's tree reader silently truncates
// fractional/overflowing integer members that its streaming reader rejects.
let read = FunSpec::builder("read").is_override()
.add_param(ParameterSpec::of("reader", TypeName::importable("com.google.gson.stream", "JsonReader")))
.returns(vec![TypeName::optional(root.clone())])
.body(sigil_quote!(Kotlin {
if (reader.peek() == $T(json_token.clone()).NULL) { reader.nextNull(); return null }
if (reader.peek() != $T(json_token).BEGIN_OBJECT) { throw $T(exception)($S(format!("expected {name} dictionary object"))) }
reader.beginObject()
val result = $T(root.clone())()
while (reader.hasNext()) {
val key = reader.nextName()
result.additionalProperties[key] = gson.fromJson(reader, object : $T(token.clone())() {}.type)
}
reader.endObject()
return result
})?).build()?;
let mut value_functions = Vec::new();
let value_function = encode_value(values, &models_package, &mut value_functions)?;
let write = FunSpec::builder("write")
.is_override()
.add_param(ParameterSpec::of(
"writer",
TypeName::importable("com.google.gson.stream", "JsonWriter"),
))
.add_param(ParameterSpec::of("value", TypeName::optional(root.clone())))
.body(sigil_quote!(Kotlin {
if (value == null) { writer.nullValue(); return }
writer.beginObject()
for (entry in value.additionalProperties.entries) {
val tree = $N(value_function)(entry.value)
writer.name(entry.key)
val serializeNulls = writer.serializeNulls
writer.serializeNulls = true
try { gson.getAdapter($T(element)::class.java).write(writer, tree) }
finally { writer.serializeNulls = serializeNulls }
}
writer.endObject()
})?)
.build()?;
let mut adapter = TypeSpec::builder(&adapter_name, TypeKind::Class)
.visibility(Visibility::Private)
.extends(type_application(
TypeName::importable("com.google.gson", "TypeAdapter"),
vec![root],
))
.add_field(
FieldSpec::builder("gson", gson.clone())
.visibility(Visibility::Private)
.is_readonly()
.initializer(sigil_quote!(Kotlin { $T(gson)() })?.__sigil_trim_trailing_newline())
.build()?,
)
.add_method(read)
.add_method(write);
for function in value_functions {
adapter = adapter.add_method(function);
}
FileSpec::builder_with("model.kt", Kotlin::new())
.header(package_header(package_name)?)
.add_type(dictionary.build()?)
.add_type(adapter.build()?)
.build()
}

/// Containers preserve their entries without forcing serializeNulls on ordinary
/// model codecs. Named members still serialize through their declared adapter.
fn encode_value(
expr: &IrTypeExpr,
models_package: &str,
functions: &mut Vec<FunSpec>,
) -> Result<String, SigilStitchError> {
let expr = expr.representation();
if let IrTypeExpr::Nullable(inner) = expr {
return encode_value(inner, models_package, functions);
}
let body = match expr {
IrTypeExpr::Any | IrTypeExpr::Union(_) => sigil_quote!(Kotlin { return value })?,
IrTypeExpr::Array(inner) => {
let child = encode_value(inner, models_package, functions)?;
let array = TypeName::importable("com.google.gson", "JsonArray");
sigil_quote!(Kotlin {
val result = $T(array)()
for (item in value) { result.add($N(child)(item)) }
return result
})?
}
IrTypeExpr::Map(inner) => {
let child = encode_value(inner, models_package, functions)?;
let object = TypeName::importable("com.google.gson", "JsonObject");
sigil_quote!(Kotlin {
val result = $T(object)()
for (entry in value.entries) { result.add(entry.key, $N(child)(entry.value)) }
return result
})?
}
_ => {
let token = type_application(
TypeName::importable("com.google.gson.reflect", "TypeToken"),
vec![kotlin_type_name_in(expr, Some(models_package))],
);
sigil_quote!(Kotlin { return gson.toJsonTree(value, object : $T(token)() {}.type) })?
}
};
let name = format!("dictionaryValue{}", functions.len());
let null = TypeName::importable("com.google.gson", "JsonNull");
functions.push(
FunSpec::builder(&name)
.visibility(Visibility::Private)
.add_param(ParameterSpec::of(
"value",
TypeName::optional(kotlin_type_name_in(expr, Some(models_package))),
))
.returns(vec![TypeName::importable("com.google.gson", "JsonElement")])
.body(sigil_quote!(Kotlin {
if (value == null) { return $T(null).INSTANCE }
$C(body)
})?)
.build()?,
);
Ok(name)
}
1 change: 1 addition & 0 deletions src/generators/kotlin/okhttp/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
pub mod codegen;
pub mod config;
mod dictionary;
mod matching;
pub mod runtime;
pub mod sigil_emit;
Expand Down
72 changes: 61 additions & 11 deletions src/generators/kotlin/okhttp/sigil_emit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ fn emit_model_body(
file_spec.render(RENDER_WIDTH)
}

fn package_header(package_name: &str) -> Result<CodeBlock, SigilStitchError> {
pub(super) fn package_header(package_name: &str) -> Result<CodeBlock, SigilStitchError> {
sigil_quote!(Kotlin {
package $L(format!("{package_name}.models"))
})
Expand All @@ -121,6 +121,11 @@ fn emit_object(
obj: &IrObject,
package_name: &str,
) -> Result<FileSpec, SigilStitchError> {
if obj.properties.is_empty()
&& let Some(values) = &obj.additional_properties
{
return super::dictionary::file(schema, values, package_name);
}
let name = schema.name.to_pascal_case();

let mut file =
Expand Down Expand Up @@ -383,23 +388,42 @@ fn kotlin_closed_sum_case(case: &ClosedSumCasePlan) -> Result<ClosedSumCaseSpec,
}

pub(super) fn kotlin_type_name(expr: &IrTypeExpr) -> TypeName {
kotlin_type_name_in(expr, None)
}

/// Authored model identities must remain distinct from an adapter's imports.
pub(super) fn kotlin_type_name_in(expr: &IrTypeExpr, models_package: Option<&str>) -> TypeName {
let builtin = |module: &str, name: &str| match models_package {
Some(_) => TypeName::qualified(module, name),
None => TypeName::primitive(name),
};
match expr {
IrTypeExpr::ScalarEnum { representation, .. } => kotlin_type_name(representation),
IrTypeExpr::Named(name) => TypeName::primitive(&name.to_pascal_case()),
IrTypeExpr::Primitive(primitive) => TypeName::primitive(kt_primitive(primitive)),
IrTypeExpr::ScalarEnum { representation, .. } => {
kotlin_type_name_in(representation, models_package)
}
IrTypeExpr::Named(name) => match models_package {
Some(package) => TypeName::qualified(package, &name.to_pascal_case()),
None => TypeName::primitive(&name.to_pascal_case()),
},
IrTypeExpr::Primitive(primitive) => builtin("kotlin", kt_primitive(primitive)),
IrTypeExpr::Array(inner) => TypeName::application(
TypeName::primitive("List"),
vec![TypeArgument::Single(kotlin_type_name(inner))],
builtin("kotlin.collections", "List"),
vec![TypeArgument::Single(kotlin_type_name_in(
inner,
models_package,
))],
),
IrTypeExpr::Map(inner) => TypeName::application(
TypeName::primitive("Map"),
builtin("kotlin.collections", "Map"),
vec![
TypeArgument::Single(TypeName::primitive("String")),
TypeArgument::Single(kotlin_type_name(inner)),
TypeArgument::Single(builtin("kotlin", "String")),
TypeArgument::Single(kotlin_type_name_in(inner, models_package)),
],
),
IrTypeExpr::Nullable(inner) => TypeName::optional(kotlin_type_name(inner)),
IrTypeExpr::StringLiteral(_) | IrTypeExpr::StringEnum(_) => TypeName::primitive("String"),
IrTypeExpr::Nullable(inner) => {
TypeName::optional(kotlin_type_name_in(inner, models_package))
}
IrTypeExpr::StringLiteral(_) | IrTypeExpr::StringEnum(_) => builtin("kotlin", "String"),
IrTypeExpr::Union(_) | IrTypeExpr::Any => {
TypeName::importable("com.google.gson", "JsonElement")
}
Expand Down Expand Up @@ -730,6 +754,32 @@ mod tests {
use super::*;
use sigil_stitch::assert_rendered;

#[test]
fn dictionary_value_types_keep_qualified_model_identities() {
let expr = IrTypeExpr::Map(Box::new(IrTypeExpr::Array(Box::new(IrTypeExpr::Nullable(
Box::new(IrTypeExpr::Named("JsonElement".into())),
)))));
let value_type = kotlin_type_name_in(&expr, Some("sdk.models"));
let token = crate::generators::type_application(
TypeName::importable("com.google.gson.reflect", "TypeToken"),
vec![kotlin_type_name_in(
&IrTypeExpr::Named("TypeToken".into()),
Some("sdk.models"),
)],
);
let body = sigil_quote!(Kotlin {
val values: $T(value_type)
val token = object : $T(token)() {}
})
.unwrap();
assert_rendered!(
Kotlin::new(),
width = 120,
body,
"import com.google.gson.reflect.TypeToken\n\nval values: kotlin.collections.Map<kotlin.String, kotlin.collections.List<sdk.models.JsonElement?>>\nval token = object: TypeToken<sdk.models.TypeToken>() {\n}\n",
);
}

#[test]
fn tagged_adapter_render_is_pinned() {
let plan = ClosedSumPlan {
Expand Down
Loading
Loading