From 59d92165afd27e7769a4ba49a65bbbf83a082b26 Mon Sep 17 00:00:00 2001 From: Adam Basfop Cavendish Date: Fri, 13 Nov 2026 01:58:56 +0800 Subject: [PATCH] fix: preserve Kotlin mixed-object additional properties - Retain explicit typed extension values in objects with named properties instead of silently discarding them through named-field-only Gson models. - Append defaulted mutable storage to data-class constructors so ordinary shallow copies, equality and components retain extras without shifting named components. - Keep named constructor arguments and old Java constructor entrypoints available, while documenting the need to recompile consumers after binary signatures change. - Flatten verbatim extension keys with opaque JSON precision and nulls while retaining declared model, enum, nullable, container, alias and recursive codecs. - Disambiguate storage and private factory names against authored declarations and reject extension keys that would overwrite declared or configured wire fields. - Reuse configured Gson delegates for named fields, preserving native stream/tree coercion, numeric output and ordinary null omission without leaking writer state. - Retain typed HTTP request, success and lazy error payloads alongside native consumer contracts and exact render and golden coverage. - Keep pure dictionaries, shared lowering, other languages, opaque-alias codecs, allOf, tagged-wrapper extension storage and generation formatting separate. --- docs/src/kotlin_config.md | 51 ++- .../kotlin/okhttp/additional_properties.rs | 321 ++++++++++++++++++ src/generators/kotlin/okhttp/codegen.rs | 4 + src/generators/kotlin/okhttp/mod.rs | 1 + src/generators/kotlin/okhttp/runtime.rs | 17 +- ...ditional_properties_adapter_factory.kt.txt | 132 +++++++ src/generators/kotlin/okhttp/sigil_emit.rs | 15 +- .../valid/kotlin-additional-helper-types.yaml | 32 ++ .../valid/kotlin-additional-properties.yaml | 174 ++++++++++ tests/generated-clients/jvm/README.md | 3 + .../additional-helper-types/gradle.lockfile | 25 ++ .../test/java/AdditionalHelperTypesTest.java | 35 ++ .../additional-properties-raw/build.gradle | 1 + .../additional-properties-raw/gradle.lockfile | 25 ++ .../test/java/AdditionalPropertiesTest.java | 1 + .../additional-properties-strict/build.gradle | 1 + .../gradle.lockfile | 25 ++ .../test/java/AdditionalPropertiesTest.java | 12 + .../cases/additional-properties/build.gradle | 9 + .../additional-properties/gradle.lockfile | 38 +++ .../test/java/AdditionalPropertiesTest.java | 1 + .../src/test/kotlin/MixedObjectSourceTest.kt | 39 +++ .../generated-clients/jvm/kotlin/fixtures.txt | 4 + .../AdditionalPropertiesContracts.java | 294 ++++++++++++++++ .../additional-helper-types/README.md.golden | 7 + .../build.gradle.kts.golden | 27 ++ .../models/Gson.kt.golden | 47 +++ ...ditionalPropertiesAdapterFactory.kt.golden | 8 + .../models/JsonElement.kt.golden | 47 +++ .../models/Map.kt.golden | 80 +++++ ...ditionalPropertiesAdapterFactory.kt.golden | 136 ++++++++ .../runtime/ApiClient.kt.golden | 61 ++++ .../runtime/ApiException.kt.golden | 11 + .../runtime/Auth.kt.golden | 70 ++++ ...jectWithAdditionalPropertiesTrue.kt.golden | 38 ++- ...ectWithTypedAdditionalProperties.kt.golden | 38 ++- ...ditionalPropertiesAdapterFactory.kt.golden | 137 ++++++++ .../README.md.golden | 7 + .../apis/DefaultApi.kt.golden | 105 ++++++ .../build.gradle.kts.golden | 27 ++ .../models/AliasValues.kt.golden | 47 +++ .../models/AmbiguousNumber.kt.golden | 116 +++++++ .../models/AmbiguousValues.kt.golden | 48 +++ .../models/Choices.kt.golden | 47 +++ .../models/Closed.kt.golden | 11 + .../models/DeepValues.kt.golden | 80 +++++ .../models/Holder.kt.golden | 8 + .../models/Implicit.kt.golden | 11 + .../models/IntegerValues.kt.golden | 47 +++ .../models/LongValues.kt.golden | 47 +++ .../models/MixedChoice.kt.golden | 104 ++++++ .../models/MixedMap.kt.golden | 47 +++ .../models/NamedOpaque.kt.golden | 49 +++ .../models/NestedValue.kt.golden | 11 + .../models/NullableValues.kt.golden | 48 +++ .../models/OpaqueMap.kt.golden | 47 +++ .../models/OpenNumbers.kt.golden | 50 +++ .../models/OptionalKnown.kt.golden | 47 +++ .../models/PlainNumbers.kt.golden | 12 + .../models/RawContainers.kt.golden | 78 +++++ .../models/Recursive.kt.golden | 47 +++ .../models/Status.kt.golden | 14 + .../models/StatusValues.kt.golden | 47 +++ .../models/StorageCollision.kt.golden | 44 +++ .../models/TypedMap.kt.golden | 71 ++++ .../models/ValueAlias.kt.golden | 7 + .../models/ValueChoice.kt.golden | 127 +++++++ ...ditionalPropertiesAdapterFactory.kt.golden | 136 ++++++++ .../runtime/ApiClient.kt.golden | 61 ++++ .../runtime/ApiException.kt.golden | 11 + .../runtime/Auth.kt.golden | 70 ++++ .../models/EventCompleted.kt.golden | 38 ++- .../models/EventSearching.kt.golden | 39 ++- ...ditionalPropertiesAdapterFactory.kt.golden | 136 ++++++++ tests/golden_tests_kotlin_okhttp.rs | 4 + tests/kotlin_additional_properties.rs | 34 ++ 76 files changed, 3936 insertions(+), 11 deletions(-) create mode 100644 src/generators/kotlin/okhttp/additional_properties.rs create mode 100644 src/generators/kotlin/okhttp/runtime/additional_properties_adapter_factory.kt.txt create mode 100644 tests/fixtures/valid/kotlin-additional-helper-types.yaml create mode 100644 tests/fixtures/valid/kotlin-additional-properties.yaml create mode 100644 tests/generated-clients/jvm/kotlin/cases/additional-helper-types/gradle.lockfile create mode 100644 tests/generated-clients/jvm/kotlin/cases/additional-helper-types/src/test/java/AdditionalHelperTypesTest.java create mode 100644 tests/generated-clients/jvm/kotlin/cases/additional-properties-raw/build.gradle create mode 100644 tests/generated-clients/jvm/kotlin/cases/additional-properties-raw/gradle.lockfile create mode 100644 tests/generated-clients/jvm/kotlin/cases/additional-properties-raw/src/test/java/AdditionalPropertiesTest.java create mode 100644 tests/generated-clients/jvm/kotlin/cases/additional-properties-strict/build.gradle create mode 100644 tests/generated-clients/jvm/kotlin/cases/additional-properties-strict/gradle.lockfile create mode 100644 tests/generated-clients/jvm/kotlin/cases/additional-properties-strict/src/test/java/AdditionalPropertiesTest.java create mode 100644 tests/generated-clients/jvm/kotlin/cases/additional-properties/build.gradle create mode 100644 tests/generated-clients/jvm/kotlin/cases/additional-properties/gradle.lockfile create mode 100644 tests/generated-clients/jvm/kotlin/cases/additional-properties/src/test/java/AdditionalPropertiesTest.java create mode 100644 tests/generated-clients/jvm/kotlin/cases/additional-properties/src/test/kotlin/MixedObjectSourceTest.kt create mode 100644 tests/generated-clients/jvm/kotlin/support/additional-properties/AdditionalPropertiesContracts.java create mode 100644 tests/golden/kotlin/kotlin-okhttp/additional-helper-types/README.md.golden create mode 100644 tests/golden/kotlin/kotlin-okhttp/additional-helper-types/build.gradle.kts.golden create mode 100644 tests/golden/kotlin/kotlin-okhttp/additional-helper-types/models/Gson.kt.golden create mode 100644 tests/golden/kotlin/kotlin-okhttp/additional-helper-types/models/GsonAdditionalPropertiesAdapterFactory.kt.golden create mode 100644 tests/golden/kotlin/kotlin-okhttp/additional-helper-types/models/JsonElement.kt.golden create mode 100644 tests/golden/kotlin/kotlin-okhttp/additional-helper-types/models/Map.kt.golden create mode 100644 tests/golden/kotlin/kotlin-okhttp/additional-helper-types/runtime/AdditionalPropertiesAdapterFactory.kt.golden create mode 100644 tests/golden/kotlin/kotlin-okhttp/additional-helper-types/runtime/ApiClient.kt.golden create mode 100644 tests/golden/kotlin/kotlin-okhttp/additional-helper-types/runtime/ApiException.kt.golden create mode 100644 tests/golden/kotlin/kotlin-okhttp/additional-helper-types/runtime/Auth.kt.golden create mode 100644 tests/golden/kotlin/kotlin-okhttp/comprehensive-schemas/runtime/AdditionalPropertiesAdapterFactory.kt.golden create mode 100644 tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/README.md.golden create mode 100644 tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/apis/DefaultApi.kt.golden create mode 100644 tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/build.gradle.kts.golden create mode 100644 tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/AliasValues.kt.golden create mode 100644 tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/AmbiguousNumber.kt.golden create mode 100644 tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/AmbiguousValues.kt.golden create mode 100644 tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/Choices.kt.golden create mode 100644 tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/Closed.kt.golden create mode 100644 tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/DeepValues.kt.golden create mode 100644 tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/Holder.kt.golden create mode 100644 tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/Implicit.kt.golden create mode 100644 tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/IntegerValues.kt.golden create mode 100644 tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/LongValues.kt.golden create mode 100644 tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/MixedChoice.kt.golden create mode 100644 tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/MixedMap.kt.golden create mode 100644 tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/NamedOpaque.kt.golden create mode 100644 tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/NestedValue.kt.golden create mode 100644 tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/NullableValues.kt.golden create mode 100644 tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/OpaqueMap.kt.golden create mode 100644 tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/OpenNumbers.kt.golden create mode 100644 tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/OptionalKnown.kt.golden create mode 100644 tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/PlainNumbers.kt.golden create mode 100644 tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/RawContainers.kt.golden create mode 100644 tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/Recursive.kt.golden create mode 100644 tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/Status.kt.golden create mode 100644 tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/StatusValues.kt.golden create mode 100644 tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/StorageCollision.kt.golden create mode 100644 tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/TypedMap.kt.golden create mode 100644 tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/ValueAlias.kt.golden create mode 100644 tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/ValueChoice.kt.golden create mode 100644 tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/runtime/AdditionalPropertiesAdapterFactory.kt.golden create mode 100644 tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/runtime/ApiClient.kt.golden create mode 100644 tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/runtime/ApiException.kt.golden create mode 100644 tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/runtime/Auth.kt.golden create mode 100644 tests/golden/kotlin/kotlin-okhttp/open-tagged-additional-properties/runtime/AdditionalPropertiesAdapterFactory.kt.golden create mode 100644 tests/kotlin_additional_properties.rs diff --git a/docs/src/kotlin_config.md b/docs/src/kotlin_config.md index ebc2ccc90..114161734 100644 --- a/docs/src/kotlin_config.md +++ b/docs/src/kotlin_config.md @@ -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. Pure dictionaries have the representation -described below; mixed-object additional-property repair remains separate. +unions keep their existing APIs/codecs. Explicit dictionary and mixed-object +additional properties have the representations described below. Raw mode uses Gson's native tree adapter and temporarily enables writer null serialization, restoring its prior value even on failure. This preserves null @@ -72,6 +72,47 @@ 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. +they share names such as `TypeToken` or `JsonElement`. Mixed-object preservation +is described below; `allOf` wrapper redesign remains separate. + +## Mixed-object additional properties + +An object with named fields and explicit `additionalProperties` gets a typed +`var additionalProperties: MutableMap` as its final defaulted primary +constructor property. Each instance starts with an independent empty map. + +```kotlin +val value = MixedMap(knownField = "fixed") +value.additionalProperties["keep_key"] = NestedValue(wireValue = "nested") +// Gson writes {"known_field":"fixed","keep_key":{"wire_value":"nested"}} +val duplicate = value.copy() // Retains the same map, as a normal shallow copy. +``` + +Existing named constructor arguments and component positions remain unchanged; +an overload retains the old Java named-field constructor entrypoint. The appended +storage participates in generated copy, equality, hash and component operations. +Adding a data-class property changes generated binary signatures: regenerate and +recompile consumers rather than mixing old and new SDK binaries. + +The storage field is transient to Gson's ordinary delegate, and the flat-object +adapter handles it separately. Extension keys stay verbatim. A storage-name clash +adds a numeric suffix such as `additionalProperties2`, without renaming authored +members. An extension key equal to a declared wire key raises +`IllegalArgumentException` on writing, even if that named field is omitted. + +Opaque values (`true` or `{}`) use `JsonElement` to retain null entries and large +numeric literals. Declared models, enums, containers, nullable values, recursive +references and transparent aliases keep their native value codecs; union values +retain the configured selection policy. The actual configured Gson instance +continues to own named fields, field naming and custom value adapters. + +Native streaming/tree coercion differences, configured numeric writing and +ordinary null omission are retained. Extension entry nulls do not force null +fields inside ordinary model values or alter the surrounding writer's policy, +including on failure. This support uses the tree reader/writer implementation +classes from the unchanged Gson 2.11.0 dependency; native consumers cover both +input modes. + +Pure-dictionary codecs are unchanged. Closed/implicit objects, general inline +composition lowering, opaque named-alias codecs, multi-member `allOf`, tagged +wrapper extension storage and generation formatting remain separate concerns. diff --git a/src/generators/kotlin/okhttp/additional_properties.rs b/src/generators/kotlin/okhttp/additional_properties.rs new file mode 100644 index 000000000..211ae8249 --- /dev/null +++ b/src/generators/kotlin/okhttp/additional_properties.rs @@ -0,0 +1,321 @@ +//! Mixed objects retain explicit extensions without replacing named-field codecs. + +use std::collections::BTreeSet; + +use heck::ToPascalCase; +use sigil_stitch::prelude::*; + +use crate::generators::type_application; +use crate::ir::types::{IrObject, IrSchema, IrSpec, IrTypeExpr}; + +use super::sigil_emit::kotlin_type_name_in; +use super::util::kt_decl_name; + +pub(super) struct AdditionalProperties<'a> { + name: String, + object: &'a IrObject, + values: &'a IrTypeExpr, + package: String, + root: TypeName, + factory: String, +} + +impl<'a> AdditionalProperties<'a> { + pub fn new( + schema: &IrSchema, + object: &'a IrObject, + package: &str, + ir: &IrSpec, + ) -> Option { + let values = object.additional_properties.as_ref()?; + let used = object + .properties + .values() + .map(|property| kt_decl_name(&property.name)) + .collect::>(); + let mut name = "additionalProperties".to_string(); + let mut suffix = 2; + while used.contains(&name) { + name = format!("additionalProperties{suffix}"); + suffix += 1; + } + let class = schema.name.to_pascal_case(); + let base_factory = format!("{class}AdditionalPropertiesAdapterFactory"); + let mut factory = base_factory.clone(); + let mut suffix = 2; + while ir + .schemas + .values() + .any(|schema| schema.name.to_pascal_case() == factory) + { + factory = format!("{base_factory}{suffix}"); + suffix += 1; + } + let package = format!("{package}.models"); + Some(Self { + name, + object, + values, + root: TypeName::qualified(&package, &class), + factory, + package, + }) + } + + fn map_type(&self) -> TypeName { + type_application( + TypeName::qualified("kotlin.collections", "MutableMap"), + vec![ + TypeName::qualified("kotlin", "String"), + value_type(self.values, &self.package), + ], + ) + } + + pub fn annotation(&self) -> AnnotationSpec { + // AnnotationSpec accepts only textual arguments; keep this class-literal + // spelling bounded here. The helper-name golden fixes its final shape. + AnnotationSpec::importable(TypeName::qualified( + "com.google.gson.annotations", + "JsonAdapter", + )) + .args([ + format!("{}::class", self.factory), + "nullSafe = false".into(), + ]) + } + + pub fn parameter(&self) -> Result { + ParameterSpec::builder(&self.name, self.map_type()) + .is_mutable_property() + .annotate_storage(AnnotationSpec::importable(TypeName::qualified( + "kotlin.jvm", + "Transient", + ))) + .default_value(sigil_quote!(Kotlin { linkedMapOf() })?.__sigil_trim_trailing_newline()) + .build() + } + + pub fn legacy_constructor(&self) -> Result { + let mut constructor = FunSpec::builder("constructor").is_constructor(); + let fields = self + .object + .properties + .values() + .map(|property| kt_decl_name(&property.name)) + .collect::>(); + for property in self.object.properties.values() { + let ty = named_field_type(&property.type_expr, &self.package); + let ty = if property.required && !property.nullable { + ty + } else { + optional(ty) + }; + constructor = + constructor.add_param(ParameterSpec::of(&kt_decl_name(&property.name), ty)); + } + constructor + .delegation( + sigil_quote!(Kotlin { + this($for(field in &fields; separator = ", ") { $N(field) }, linkedMapOf()) + })? + .__sigil_trim_trailing_newline(), + ) + .body(sigil_quote!(Kotlin {})?) + .build() + } + + pub fn factory(&self, package: &str) -> Result { + let value = value_type(self.values, &self.package); + let token = type_application( + TypeName::qualified("com.google.gson.reflect", "TypeToken"), + vec![value.clone()], + ); + let parent = type_application( + TypeName::qualified( + &format!("{package}.runtime"), + "AdditionalPropertiesAdapterFactory", + ), + vec![self.root.clone(), value.clone()], + ); + let constructor = FunSpec::builder("constructor").is_constructor() + .delegation(sigil_quote!(Kotlin { + super($T(self.root.clone())::class.java, object : $T(token)() {}, $for(key in self.object.properties.keys(); separator = ", ") { $S(key) }) + })?.__sigil_trim_trailing_newline()) + .body(sigil_quote!(Kotlin {})?).build()?; + let properties = FunSpec::builder("properties") + .is_override() + .add_param(ParameterSpec::of("value", self.root.clone())) + .returns(vec![self.map_type()]) + .body(sigil_quote!(Kotlin { return value.$N(&self.name) })?) + .build()?; + let setter = FunSpec::builder("setProperties") + .is_override() + .add_param(ParameterSpec::of("value", self.root.clone())) + .add_param(ParameterSpec::of("entries", self.map_type())) + .body(sigil_quote!(Kotlin { value.$N(&self.name) = entries })?) + .build()?; + let mut helpers = Vec::new(); + let function = encode_value(self.values, &self.package, &mut helpers)?; + let encode = FunSpec::builder("encode") + .is_override() + .add_param(ParameterSpec::of( + "gson", + TypeName::qualified("com.google.gson", "Gson"), + )) + .add_param(ParameterSpec::of("value", optional(value))) + .returns(vec![TypeName::qualified("com.google.gson", "JsonElement")]) + .body(sigil_quote!(Kotlin { return $N(function)(gson, value) })?) + .build()?; + let mut factory = TypeSpec::builder(&self.factory, TypeKind::Class) + .visibility(Visibility::Private) + .extends(parent) + .add_method(constructor) + .add_method(properties) + .add_method(setter) + .add_method(encode); + for helper in helpers { + factory = factory.add_method(helper); + } + factory.build() + } +} + +fn optional(ty: TypeName) -> TypeName { + if matches!(ty, TypeName::Optional(_)) { + ty + } else { + TypeName::optional(ty) + } +} + +/// Ordinary fields historically use Any, not the extension storage's JsonElement. +fn named_field_type(expr: &IrTypeExpr, package: &str) -> TypeName { + match expr.representation() { + IrTypeExpr::Any | IrTypeExpr::Union(_) => TypeName::qualified("kotlin", "Any"), + IrTypeExpr::Array(inner) => type_application( + TypeName::qualified("kotlin.collections", "List"), + vec![named_field_type(inner, package)], + ), + IrTypeExpr::Map(inner) => type_application( + TypeName::qualified("kotlin.collections", "Map"), + vec![ + TypeName::qualified("kotlin", "String"), + named_field_type(inner, package), + ], + ), + IrTypeExpr::Nullable(inner) => optional(named_field_type(inner, package)), + _ => kotlin_type_name_in(expr, Some(package)), + } +} + +fn value_type(expr: &IrTypeExpr, package: &str) -> TypeName { + match expr.representation() { + IrTypeExpr::Any | IrTypeExpr::Union(_) => { + TypeName::qualified("com.google.gson", "JsonElement") + } + IrTypeExpr::Array(inner) => type_application( + TypeName::qualified("kotlin.collections", "List"), + vec![value_type(inner, package)], + ), + IrTypeExpr::Map(inner) => type_application( + TypeName::qualified("kotlin.collections", "Map"), + vec![ + TypeName::qualified("kotlin", "String"), + value_type(inner, package), + ], + ), + IrTypeExpr::Nullable(inner) => optional(value_type(inner, package)), + _ => kotlin_type_name_in(expr, Some(package)), + } +} + +fn encode_value( + expr: &IrTypeExpr, + package: &str, + functions: &mut Vec, +) -> Result { + let expr = expr.representation(); + if let IrTypeExpr::Nullable(inner) = expr { + return encode_value(inner, package, functions); + } + let body = match expr { + IrTypeExpr::Any | IrTypeExpr::Union(_) => sigil_quote!(Kotlin { return value })?, + IrTypeExpr::Array(inner) => { + let child = encode_value(inner, package, functions)?; + sigil_quote!(Kotlin { + val result = $T(TypeName::qualified("com.google.gson", "JsonArray"))() + for (item in value) { result.add($N(child)(gson, item)) } + return result + })? + } + IrTypeExpr::Map(inner) => { + let child = encode_value(inner, package, functions)?; + sigil_quote!(Kotlin { + val result = $T(TypeName::qualified("com.google.gson", "JsonObject"))() + for (entry in value.entries) { result.add(entry.key, $N(child)(gson, entry.value)) } + return result + })? + } + _ => { + let token = type_application( + TypeName::qualified("com.google.gson.reflect", "TypeToken"), + vec![value_type(expr, package)], + ); + sigil_quote!(Kotlin { return gson.toJsonTree(value, object : $T(token)() {}.type) })? + } + }; + let function = format!("additionalValue{}", functions.len()); + functions.push(FunSpec::builder(&function).visibility(Visibility::Private) + .add_param(ParameterSpec::of("gson", TypeName::qualified("com.google.gson", "Gson"))) + .add_param(ParameterSpec::of("value", optional(value_type(expr, package)))) + .returns(vec![TypeName::qualified("com.google.gson", "JsonElement")]) + .body(sigil_quote!(Kotlin { + if (value == null) { return $T(TypeName::qualified("com.google.gson", "JsonNull")).INSTANCE } + $C(body) + })?).build()?); + Ok(function) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ir::types::IrSchemaKind; + use sigil_stitch::{assert_rendered, lang::kotlin::Kotlin}; + + #[test] + fn mixed_storage_and_legacy_constructor_render_without_renaming_authored_members() { + let source = "openapi: 3.1.0\ninfo: {title: Storage, version: '1'}\npaths: {}\ncomponents:\n schemas:\n Model:\n type: object\n required: [additionalProperties]\n properties:\n additionalProperties: {type: string}\n additionalProperties: {type: integer}\n"; + let ir = + crate::ir::lower::lower(crate::parser::parse_content_yaml(source).unwrap()).unwrap(); + let schema = &ir.schemas["Model"]; + let IrSchemaKind::Object(object) = &schema.kind else { + panic!("object facts retained") + }; + let plan = AdditionalProperties::new(schema, object, "com.example.sdk", &ir).unwrap(); + let declaration = TypeSpec::builder("Model", TypeKind::Struct) + .visibility(Visibility::Public) + .add_primary_constructor_param( + ParameterSpec::builder( + "additionalProperties", + TypeName::qualified("kotlin", "String"), + ) + .is_property() + .build() + .unwrap(), + ) + .add_primary_constructor_param(plan.parameter().unwrap()) + .add_method(plan.legacy_constructor().unwrap()) + .build() + .unwrap() + .emit(&Kotlin::new()) + .unwrap(); + let block = sigil_quote!(Kotlin { $C_each(declaration) }).unwrap(); + assert_rendered!( + Kotlin::new(), + width = 300, + block, + "data class Model(val additionalProperties: kotlin.String, @field:kotlin.jvm.Transient\n var additionalProperties2: kotlin.collections.MutableMap = linkedMapOf()) {\n constructor(additionalProperties: kotlin.String) : this(additionalProperties, linkedMapOf()) {\n\n }\n}\n" + ); + } +} diff --git a/src/generators/kotlin/okhttp/codegen.rs b/src/generators/kotlin/okhttp/codegen.rs index 3ad0c165b..a4d00d8d6 100644 --- a/src/generators/kotlin/okhttp/codegen.rs +++ b/src/generators/kotlin/okhttp/codegen.rs @@ -57,6 +57,10 @@ impl KotlinOkhttpCodeGenerator { &header, &package_name, request_inputs.has_uploads(), + ir.schemas.values().any(|schema| { + matches!(&schema.kind, crate::ir::types::IrSchemaKind::Object(object) + if !object.properties.is_empty() && object.additional_properties.is_some()) + }), )); files.push(build_gradle_file(&package_name, &ir.info)); diff --git a/src/generators/kotlin/okhttp/mod.rs b/src/generators/kotlin/okhttp/mod.rs index b29c461f9..dcee7a20c 100644 --- a/src/generators/kotlin/okhttp/mod.rs +++ b/src/generators/kotlin/okhttp/mod.rs @@ -1,3 +1,4 @@ +mod additional_properties; pub mod codegen; pub mod config; mod dictionary; diff --git a/src/generators/kotlin/okhttp/runtime.rs b/src/generators/kotlin/okhttp/runtime.rs index 76194c04e..504f1ee3c 100644 --- a/src/generators/kotlin/okhttp/runtime.rs +++ b/src/generators/kotlin/okhttp/runtime.rs @@ -5,7 +5,12 @@ const API_EXCEPTION_KT: &str = include_str!("runtime/api_exception.kt.txt"); const AUTH_KT: &str = include_str!("runtime/auth.kt.txt"); const UPLOAD_FILE_KT: &str = include_str!("runtime/upload_file.kt.txt"); -pub fn runtime_files(header: &str, package_name: &str, include_upload_file: bool) -> Vec { +pub fn runtime_files( + header: &str, + package_name: &str, + include_upload_file: bool, + include_additional_properties: bool, +) -> Vec { let runtime_package = format!("{package_name}.runtime"); let mut files = vec![ FileInfo::runtime( @@ -27,6 +32,16 @@ pub fn runtime_files(header: &str, package_name: &str, include_upload_file: bool repackage(header, UPLOAD_FILE_KT, &runtime_package), )); } + if include_additional_properties { + files.push(FileInfo::runtime( + "AdditionalPropertiesAdapterFactory.kt".to_string(), + repackage( + header, + include_str!("runtime/additional_properties_adapter_factory.kt.txt"), + &runtime_package, + ), + )); + } files } diff --git a/src/generators/kotlin/okhttp/runtime/additional_properties_adapter_factory.kt.txt b/src/generators/kotlin/okhttp/runtime/additional_properties_adapter_factory.kt.txt new file mode 100644 index 000000000..83dbce5cf --- /dev/null +++ b/src/generators/kotlin/okhttp/runtime/additional_properties_adapter_factory.kt.txt @@ -0,0 +1,132 @@ +package runtime + +import com.google.gson.Gson +import com.google.gson.JsonElement +import com.google.gson.JsonParser +import com.google.gson.JsonSyntaxException +import com.google.gson.TypeAdapter +import com.google.gson.TypeAdapterFactory +import com.google.gson.annotations.SerializedName +import com.google.gson.internal.bind.JsonTreeReader +import com.google.gson.internal.bind.JsonTreeWriter +import com.google.gson.reflect.TypeToken +import com.google.gson.stream.JsonReader +import com.google.gson.stream.JsonToken +import com.google.gson.stream.JsonWriter +import java.io.StringReader +import java.io.StringWriter +import java.lang.reflect.Modifier + +/** Internal flat-object support; named fields retain their configured Gson delegate. */ +abstract class AdditionalPropertiesAdapterFactory( + private val modelClass: Class, + private val valueType: TypeToken, + vararg declaredNames: String, +) : TypeAdapterFactory { + private val declaredNames = declaredNames.toSet() + + protected abstract fun properties(value: M): MutableMap + protected abstract fun setProperties(value: M, entries: MutableMap) + protected abstract fun encode(gson: Gson, value: V?): JsonElement + + override fun create(gson: Gson, type: TypeToken): TypeAdapter? { + val modelType: TypeToken = TypeToken.get(modelClass) + if (type.rawType != modelClass) return null + val delegate: TypeAdapter = gson.getDelegateAdapter(this, modelType) + val values: TypeAdapter = gson.getAdapter(valueType) + val trees = gson.getAdapter(JsonElement::class.java) + val known = declaredNames.toMutableSet() + for (field in modelClass.declaredFields) { + if (Modifier.isStatic(field.modifiers) || Modifier.isTransient(field.modifiers)) continue + val annotation = field.getAnnotation(SerializedName::class.java) + if (annotation == null) { + known.add(gson.fieldNamingStrategy().translateName(field)) + } else { + known.add(annotation.value) + known.addAll(annotation.alternate) + } + } + val adapter = object : TypeAdapter() { + override fun read(reader: JsonReader): M? { + if (reader.peek() == JsonToken.NULL) { + reader.nextNull() + return null + } + if (reader.peek() != JsonToken.BEGIN_OBJECT) { + throw JsonSyntaxException("Expected ${modelClass.simpleName} object") + } + val extras = linkedMapOf() + val buffer = StringWriter() + val named = JsonWriter(buffer) + named.serializeNulls = true + named.strictness = reader.strictness + named.beginObject() + reader.beginObject() + while (reader.hasNext()) { + val key = reader.nextName() + if (key in known) { + named.name(key) + trees.write(named, trees.read(reader)) + } else { + // Keep the original reader's native value conversion behavior. + extras[key] = values.read(reader) + } + } + reader.endObject() + named.endObject() + // Tree and streaming readers deliberately retain their own coercion. + val replay = if (reader is JsonTreeReader) { + JsonTreeReader(JsonParser.parseString(buffer.toString())) + } else { + JsonReader(StringReader(buffer.toString())) + } + replay.strictness = reader.strictness + val result = delegate.read(replay) + if (result != null) { + // Gson may allocate without running the Kotlin constructor. + setProperties(result, extras) + } + return result + } + + override fun write(writer: JsonWriter, value: M?) { + if (value == null) { + writer.nullValue() + return + } + val extras = properties(value) + for (key in extras.keys) { + require(key !in known) { "Additional property collides with declared wire field: $key" } + } + // Native trees preserve configured numeric values such as NaN. + val named = JsonTreeWriter() + named.serializeNulls = writer.serializeNulls + named.isHtmlSafe = writer.isHtmlSafe + named.strictness = writer.strictness + delegate.write(named, value) + val fields = named.get().asJsonObject + writer.beginObject() + for ((key, tree) in fields.entrySet()) { + writer.name(key) + trees.write(writer, tree) + } + for ((key, item) in extras) { + require(!fields.has(key)) { "Additional property collides with declared wire field: $key" } + val tree = encode(gson, item) + writer.name(key) + val serializeNulls = writer.serializeNulls + writer.serializeNulls = true + try { + trees.write(writer, tree) + } finally { + writer.serializeNulls = serializeNulls + } + } + writer.endObject() + } + } + // The checked raw-type identity proves this cast for non-generic models. + @Suppress("UNCHECKED_CAST") + return adapter as TypeAdapter + } +} diff --git a/src/generators/kotlin/okhttp/sigil_emit.rs b/src/generators/kotlin/okhttp/sigil_emit.rs index 9461f7ffc..6f9ac13bc 100644 --- a/src/generators/kotlin/okhttp/sigil_emit.rs +++ b/src/generators/kotlin/okhttp/sigil_emit.rs @@ -96,7 +96,7 @@ fn emit_model_body( strategy: UntaggedUnionStrategy, ) -> Result { let file_spec = match &schema.kind { - IrSchemaKind::Object(obj) => emit_object(schema, obj, package_name), + IrSchemaKind::Object(obj) => emit_object(schema, obj, package_name, ir), IrSchemaKind::Enum(en) => emit_enum(schema, en, package_name), IrSchemaKind::Alias(expr) => emit_alias(schema, expr, package_name), IrSchemaKind::Union(u) => emit_union(schema, u, matching_ir, package_name, strategy), @@ -120,6 +120,7 @@ fn emit_object( schema: &IrSchema, obj: &IrObject, package_name: &str, + ir: &IrSpec, ) -> Result { if obj.properties.is_empty() && let Some(values) = &obj.additional_properties @@ -128,6 +129,9 @@ fn emit_object( } let name = schema.name.to_pascal_case(); + let additional = + super::additional_properties::AdditionalProperties::new(schema, obj, package_name, ir); + let mut file = FileSpec::builder_with("model.kt", Kotlin::new()).header(package_header(package_name)?); @@ -157,7 +161,16 @@ fn emit_object( tb = tb.add_primary_constructor_param(param); } + if let Some(additional) = &additional { + tb = tb + .annotate(additional.annotation()) + .add_primary_constructor_param(additional.parameter()?) + .add_method(additional.legacy_constructor()?); + } file = file.add_type(tb.build()?); + if let Some(additional) = &additional { + file = file.add_type(additional.factory(package_name)?); + } file.build() } diff --git a/tests/fixtures/valid/kotlin-additional-helper-types.yaml b/tests/fixtures/valid/kotlin-additional-helper-types.yaml new file mode 100644 index 000000000..d6bcd3160 --- /dev/null +++ b/tests/fixtures/valid/kotlin-additional-helper-types.yaml @@ -0,0 +1,32 @@ +openapi: 3.1.0 +info: {title: Kotlin additional helper types, version: '1.0.0'} +paths: {} +components: + schemas: + JsonElement: + type: object + required: [known_field] + properties: + known_field: {type: string} + additionalProperties: true + Map: + type: object + required: [known_field] + properties: + known_field: {type: string} + additionalProperties: + type: array + items: + type: object + additionalProperties: {$ref: '#/components/schemas/JsonElement'} + Gson: + type: object + required: [known_field] + properties: + known_field: {type: string} + additionalProperties: {type: integer, format: int64} + GsonAdditionalPropertiesAdapterFactory: + type: object + required: [label] + properties: + label: {type: string} diff --git a/tests/fixtures/valid/kotlin-additional-properties.yaml b/tests/fixtures/valid/kotlin-additional-properties.yaml new file mode 100644 index 000000000..e6f971d39 --- /dev/null +++ b/tests/fixtures/valid/kotlin-additional-properties.yaml @@ -0,0 +1,174 @@ +openapi: 3.1.0 +info: {title: Kotlin additional properties, version: '1.0.0'} +paths: + /mixed: + post: + operationId: echoMixed + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/MixedMap'} + responses: + '200': + description: Mixed object + content: + application/json: + schema: {$ref: '#/components/schemas/MixedMap'} + '400': + description: Mixed typed error + content: + application/json: + schema: {$ref: '#/components/schemas/MixedMap'} +components: + schemas: + NestedValue: + type: object + required: [wire_value] + properties: + wire_value: {type: string} + OpaqueMap: + type: object + required: [known_field] + properties: + known_field: {type: string} + additionalProperties: true + MixedMap: + type: object + required: [known_field] + properties: + known_field: {type: string} + additionalProperties: {$ref: '#/components/schemas/NestedValue'} + TypedMap: + type: object + additionalProperties: {$ref: '#/components/schemas/NestedValue'} + LongValues: + type: object + required: [known_field] + properties: + known_field: {type: string} + additionalProperties: {type: integer, format: int64} + IntegerValues: + type: object + required: [known_field] + properties: + known_field: {type: string} + additionalProperties: {type: integer} + Status: + type: string + enum: [active, inactive] + StatusValues: + type: object + required: [known_field] + properties: + known_field: {type: string} + additionalProperties: {$ref: '#/components/schemas/Status'} + NullableValues: + type: object + required: [known_field] + properties: + known_field: {type: string} + additionalProperties: + anyOf: [{$ref: '#/components/schemas/NestedValue'}, {type: 'null'}] + DeepValues: + type: object + required: [known_field] + properties: + known_field: {type: string} + additionalProperties: + type: array + items: + type: object + additionalProperties: {$ref: '#/components/schemas/NestedValue'} + RawContainers: + type: object + required: [known_field] + properties: + known_field: {type: string} + additionalProperties: + type: array + items: + type: object + additionalProperties: true + ValueAlias: {$ref: '#/components/schemas/NestedValue'} + AliasValues: + type: object + required: [known_field] + properties: + known_field: {type: string} + additionalProperties: {$ref: '#/components/schemas/ValueAlias'} + Choices: + type: object + required: [known_field] + properties: + known_field: {type: string} + additionalProperties: {$ref: '#/components/schemas/ValueChoice'} + ValueChoice: + anyOf: [{$ref: '#/components/schemas/NestedValue'}, {type: integer}] + AmbiguousValues: + type: object + required: [known_field] + properties: + known_field: {type: string} + additionalProperties: {$ref: '#/components/schemas/AmbiguousNumber'} + AmbiguousNumber: + anyOf: [{type: integer}, {type: number}] + MixedChoice: + anyOf: [{$ref: '#/components/schemas/MixedMap'}, {type: string}] + Recursive: + type: object + required: [wire_value] + properties: + wire_value: {type: string} + additionalProperties: {$ref: '#/components/schemas/Recursive'} + StorageCollision: + type: object + required: [additionalProperties] + properties: + additionalProperties: {type: string} + additionalProperties: {$ref: '#/components/schemas/NestedValue'} + OptionalKnown: + type: object + properties: + known_field: {type: string} + additionalProperties: true + NamedOpaque: + type: object + required: [known_field] + properties: + known_field: {} + known_values: {type: array, items: {}} + additionalProperties: true + OpenNumbers: + type: object + required: [count] + properties: + count: {type: integer} + optional_count: {type: integer} + nullable_value: {$ref: '#/components/schemas/NestedValue'} + weight: {type: number} + additionalProperties: true + PlainNumbers: + type: object + required: [count] + properties: + count: {type: integer} + optional_count: {type: integer} + nullable_value: {$ref: '#/components/schemas/NestedValue'} + weight: {type: number} + Closed: + type: object + properties: + known_field: {type: string} + additionalProperties: false + Implicit: + type: object + properties: + known_field: {type: string} + Holder: + type: object + required: [mixed, opaque, dictionaries] + properties: + mixed: {$ref: '#/components/schemas/MixedMap'} + opaque: {$ref: '#/components/schemas/OpaqueMap'} + dictionaries: {type: array, items: {$ref: '#/components/schemas/TypedMap'}} diff --git a/tests/generated-clients/jvm/README.md b/tests/generated-clients/jvm/README.md index a95d60fc4..e76747463 100644 --- a/tests/generated-clients/jvm/README.md +++ b/tests/generated-clients/jvm/README.md @@ -20,6 +20,9 @@ The Kotlin dictionary consumer checks flat typed/opaque maps, null entries, recursive values, native integer decode errors, containing models and HTTP request/response/error boundaries. Its helper-type consumer also verifies authored models and dictionaries named after Gson helpers retain their declared identities. +Kotlin's mixed-object consumers additionally cover default/strict/raw policies, +old Java constructor entrypoints, copy/components, typed transient storage, +configured Gson delegates, native stream/tree behavior and HTTP mixed payloads. Generated manifests and source are used unchanged. Each fixture has a separate consumer subproject, so identically named SDK classes do not share a classpath. diff --git a/tests/generated-clients/jvm/kotlin/cases/additional-helper-types/gradle.lockfile b/tests/generated-clients/jvm/kotlin/cases/additional-helper-types/gradle.lockfile new file mode 100644 index 000000000..84223e026 --- /dev/null +++ b/tests/generated-clients/jvm/kotlin/cases/additional-helper-types/gradle.lockfile @@ -0,0 +1,25 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +# To regenerate this file, run: ./gradlew :cases:additional-helper-types:dependencies --write-locks +com.google.code.gson:gson:2.11.0=testCompileClasspath,testRuntimeClasspath +com.google.errorprone:error_prone_annotations:2.27.0=testCompileClasspath,testRuntimeClasspath +com.squareup.okhttp3:okhttp:4.12.0=testCompileClasspath,testRuntimeClasspath +com.squareup.okio:okio-jvm:3.6.0=testCompileClasspath,testRuntimeClasspath +com.squareup.okio:okio:3.6.0=testCompileClasspath,testRuntimeClasspath +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.jetbrains.kotlin:kotlin-stdlib-common:2.2.21=testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.10=testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.10=testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib:2.2.21=testCompileClasspath,testRuntimeClasspath +org.jetbrains:annotations:13.0=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:5.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:5.12.2=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:5.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:5.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:1.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:1.12.2=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:1.12.2=testRuntimeClasspath +org.junit:junit-bom:5.12.2=testCompileClasspath,testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +empty=testAnnotationProcessor diff --git a/tests/generated-clients/jvm/kotlin/cases/additional-helper-types/src/test/java/AdditionalHelperTypesTest.java b/tests/generated-clients/jvm/kotlin/cases/additional-helper-types/src/test/java/AdditionalHelperTypesTest.java new file mode 100644 index 000000000..18255581b --- /dev/null +++ b/tests/generated-clients/jvm/kotlin/cases/additional-helper-types/src/test/java/AdditionalHelperTypesTest.java @@ -0,0 +1,35 @@ +import static org.junit.jupiter.api.Assertions.*; + +import com.google.gson.Gson; +import com.google.gson.JsonParser; +import org.junit.jupiter.api.Test; + +class AdditionalHelperTypesTest { + private final Gson gson = new Gson(); + + private T roundTrip(String wire, Class type) { + T value = gson.fromJson(wire, type); + assertEquals(JsonParser.parseString(wire), gson.toJsonTree(value, type)); + return value; + } + + @Test void authoredJsonElementDoesNotReplaceOpaqueGsonValues() { + com.example.sdk.models.JsonElement value = roundTrip("{\"known_field\":\"fixed\",\"nil\":null,\"large\":9007199254740993}", com.example.sdk.models.JsonElement.class); + assertTrue(value.getAdditionalProperties().get("nil").isJsonNull()); + } + + @Test void nestedAuthoredTypesStayQualifiedInMixedMapStorage() { + com.example.sdk.models.Map value = roundTrip("{\"known_field\":\"fixed\",\"list\":[{\"key\":{\"known_field\":\"nested\",\"nil\":null}}]}", com.example.sdk.models.Map.class); + assertInstanceOf(com.example.sdk.models.JsonElement.class, value.getAdditionalProperties().get("list").get(0).get("key")); + } + + @Test void authoredGsonKeepsItsOwnFactoryAndPrimitiveValues() { + com.example.sdk.models.Gson value = roundTrip("{\"known_field\":\"fixed\",\"large\":9007199254740993}", com.example.sdk.models.Gson.class); + assertEquals(9007199254740993L, value.getAdditionalProperties().get("large")); + } + + @Test void authoredFactoryLookingNamesRemainOrdinaryModels() { + com.example.sdk.models.GsonAdditionalPropertiesAdapterFactory value = new com.example.sdk.models.GsonAdditionalPropertiesAdapterFactory("authored"); + assertEquals("{\"label\":\"authored\"}", gson.toJson(value)); + } +} diff --git a/tests/generated-clients/jvm/kotlin/cases/additional-properties-raw/build.gradle b/tests/generated-clients/jvm/kotlin/cases/additional-properties-raw/build.gradle new file mode 100644 index 000000000..ea2b6c072 --- /dev/null +++ b/tests/generated-clients/jvm/kotlin/cases/additional-properties-raw/build.gradle @@ -0,0 +1 @@ +sourceSets.test.java.srcDir("../../support/additional-properties") diff --git a/tests/generated-clients/jvm/kotlin/cases/additional-properties-raw/gradle.lockfile b/tests/generated-clients/jvm/kotlin/cases/additional-properties-raw/gradle.lockfile new file mode 100644 index 000000000..82b53b064 --- /dev/null +++ b/tests/generated-clients/jvm/kotlin/cases/additional-properties-raw/gradle.lockfile @@ -0,0 +1,25 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +# To regenerate this file, run: ./gradlew :cases:additional-properties-raw:dependencies --write-locks +com.google.code.gson:gson:2.11.0=testCompileClasspath,testRuntimeClasspath +com.google.errorprone:error_prone_annotations:2.27.0=testCompileClasspath,testRuntimeClasspath +com.squareup.okhttp3:okhttp:4.12.0=testCompileClasspath,testRuntimeClasspath +com.squareup.okio:okio-jvm:3.6.0=testCompileClasspath,testRuntimeClasspath +com.squareup.okio:okio:3.6.0=testCompileClasspath,testRuntimeClasspath +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.jetbrains.kotlin:kotlin-stdlib-common:2.2.21=testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.10=testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.10=testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib:2.2.21=testCompileClasspath,testRuntimeClasspath +org.jetbrains:annotations:13.0=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:5.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:5.12.2=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:5.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:5.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:1.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:1.12.2=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:1.12.2=testRuntimeClasspath +org.junit:junit-bom:5.12.2=testCompileClasspath,testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +empty=testAnnotationProcessor diff --git a/tests/generated-clients/jvm/kotlin/cases/additional-properties-raw/src/test/java/AdditionalPropertiesTest.java b/tests/generated-clients/jvm/kotlin/cases/additional-properties-raw/src/test/java/AdditionalPropertiesTest.java new file mode 100644 index 000000000..044d569fc --- /dev/null +++ b/tests/generated-clients/jvm/kotlin/cases/additional-properties-raw/src/test/java/AdditionalPropertiesTest.java @@ -0,0 +1 @@ +class AdditionalPropertiesTest extends AdditionalPropertiesContracts {} diff --git a/tests/generated-clients/jvm/kotlin/cases/additional-properties-strict/build.gradle b/tests/generated-clients/jvm/kotlin/cases/additional-properties-strict/build.gradle new file mode 100644 index 000000000..ea2b6c072 --- /dev/null +++ b/tests/generated-clients/jvm/kotlin/cases/additional-properties-strict/build.gradle @@ -0,0 +1 @@ +sourceSets.test.java.srcDir("../../support/additional-properties") diff --git a/tests/generated-clients/jvm/kotlin/cases/additional-properties-strict/gradle.lockfile b/tests/generated-clients/jvm/kotlin/cases/additional-properties-strict/gradle.lockfile new file mode 100644 index 000000000..5a87226d1 --- /dev/null +++ b/tests/generated-clients/jvm/kotlin/cases/additional-properties-strict/gradle.lockfile @@ -0,0 +1,25 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +# To regenerate this file, run: ./gradlew :cases:additional-properties-strict:dependencies --write-locks +com.google.code.gson:gson:2.11.0=testCompileClasspath,testRuntimeClasspath +com.google.errorprone:error_prone_annotations:2.27.0=testCompileClasspath,testRuntimeClasspath +com.squareup.okhttp3:okhttp:4.12.0=testCompileClasspath,testRuntimeClasspath +com.squareup.okio:okio-jvm:3.6.0=testCompileClasspath,testRuntimeClasspath +com.squareup.okio:okio:3.6.0=testCompileClasspath,testRuntimeClasspath +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.jetbrains.kotlin:kotlin-stdlib-common:2.2.21=testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.10=testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.10=testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib:2.2.21=testCompileClasspath,testRuntimeClasspath +org.jetbrains:annotations:13.0=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:5.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:5.12.2=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:5.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:5.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:1.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:1.12.2=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:1.12.2=testRuntimeClasspath +org.junit:junit-bom:5.12.2=testCompileClasspath,testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +empty=testAnnotationProcessor diff --git a/tests/generated-clients/jvm/kotlin/cases/additional-properties-strict/src/test/java/AdditionalPropertiesTest.java b/tests/generated-clients/jvm/kotlin/cases/additional-properties-strict/src/test/java/AdditionalPropertiesTest.java new file mode 100644 index 000000000..dd74c5c42 --- /dev/null +++ b/tests/generated-clients/jvm/kotlin/cases/additional-properties-strict/src/test/java/AdditionalPropertiesTest.java @@ -0,0 +1,12 @@ +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.example.sdk.models.AmbiguousValues; +import com.google.gson.Gson; +import com.google.gson.JsonParseException; +import org.junit.jupiter.api.Test; + +class AdditionalPropertiesTest extends AdditionalPropertiesContracts { + @Test void extensionValuesRetainStrictUnionAmbiguityErrors() { + assertThrows(JsonParseException.class, () -> new Gson().fromJson("{\"known_field\":\"fixed\",\"value\":2}", AmbiguousValues.class)); + } +} diff --git a/tests/generated-clients/jvm/kotlin/cases/additional-properties/build.gradle b/tests/generated-clients/jvm/kotlin/cases/additional-properties/build.gradle new file mode 100644 index 000000000..a3d78b47e --- /dev/null +++ b/tests/generated-clients/jvm/kotlin/cases/additional-properties/build.gradle @@ -0,0 +1,9 @@ +plugins { id("org.jetbrains.kotlin.jvm") version "2.2.21" } + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} +kotlin { compilerOptions { jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 } } + +sourceSets.test.java.srcDir("../../support/additional-properties") diff --git a/tests/generated-clients/jvm/kotlin/cases/additional-properties/gradle.lockfile b/tests/generated-clients/jvm/kotlin/cases/additional-properties/gradle.lockfile new file mode 100644 index 000000000..5f3ec0151 --- /dev/null +++ b/tests/generated-clients/jvm/kotlin/cases/additional-properties/gradle.lockfile @@ -0,0 +1,38 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +# To regenerate this file, run: ./gradlew :cases:additional-properties:dependencies --write-locks +com.google.code.gson:gson:2.11.0=testCompileClasspath,testRuntimeClasspath +com.google.errorprone:error_prone_annotations:2.27.0=testCompileClasspath,testRuntimeClasspath +com.squareup.okhttp3:okhttp:4.12.0=testCompileClasspath,testRuntimeClasspath +com.squareup.okio:okio-jvm:3.6.0=testCompileClasspath,testRuntimeClasspath +com.squareup.okio:okio:3.6.0=testCompileClasspath,testRuntimeClasspath +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.jetbrains.kotlin:kotlin-build-tools-api:2.2.21=kotlinBuildToolsApiClasspath +org.jetbrains.kotlin:kotlin-build-tools-impl:2.2.21=kotlinBuildToolsApiClasspath +org.jetbrains.kotlin:kotlin-compiler-embeddable:2.2.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-compiler-runner:2.2.21=kotlinBuildToolsApiClasspath +org.jetbrains.kotlin:kotlin-daemon-client:2.2.21=kotlinBuildToolsApiClasspath +org.jetbrains.kotlin:kotlin-daemon-embeddable:2.2.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-reflect:1.6.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-script-runtime:2.2.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathTest +org.jetbrains.kotlin:kotlin-scripting-common:2.2.21=kotlinBuildToolsApiClasspath,kotlinCompilerPluginClasspathTest +org.jetbrains.kotlin:kotlin-scripting-compiler-embeddable:2.2.21=kotlinBuildToolsApiClasspath,kotlinCompilerPluginClasspathTest +org.jetbrains.kotlin:kotlin-scripting-compiler-impl-embeddable:2.2.21=kotlinBuildToolsApiClasspath,kotlinCompilerPluginClasspathTest +org.jetbrains.kotlin:kotlin-scripting-jvm:2.2.21=kotlinBuildToolsApiClasspath,kotlinCompilerPluginClasspathTest +org.jetbrains.kotlin:kotlin-stdlib-common:2.2.21=testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.10=testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.10=testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib:2.2.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathTest,testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.8.0=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains:annotations:13.0=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathTest,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:5.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:5.12.2=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:5.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:5.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:1.12.2=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:1.12.2=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:1.12.2=testRuntimeClasspath +org.junit:junit-bom:5.12.2=testCompileClasspath,testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +empty=kotlinScriptDefExtensions,testAnnotationProcessor,testKotlinScriptDefExtensions diff --git a/tests/generated-clients/jvm/kotlin/cases/additional-properties/src/test/java/AdditionalPropertiesTest.java b/tests/generated-clients/jvm/kotlin/cases/additional-properties/src/test/java/AdditionalPropertiesTest.java new file mode 100644 index 000000000..044d569fc --- /dev/null +++ b/tests/generated-clients/jvm/kotlin/cases/additional-properties/src/test/java/AdditionalPropertiesTest.java @@ -0,0 +1 @@ +class AdditionalPropertiesTest extends AdditionalPropertiesContracts {} diff --git a/tests/generated-clients/jvm/kotlin/cases/additional-properties/src/test/kotlin/MixedObjectSourceTest.kt b/tests/generated-clients/jvm/kotlin/cases/additional-properties/src/test/kotlin/MixedObjectSourceTest.kt new file mode 100644 index 000000000..a59241a5c --- /dev/null +++ b/tests/generated-clients/jvm/kotlin/cases/additional-properties/src/test/kotlin/MixedObjectSourceTest.kt @@ -0,0 +1,39 @@ +import com.example.sdk.models.MixedMap +import com.example.sdk.models.NestedValue +import com.example.sdk.models.OptionalKnown +import com.example.sdk.models.StorageCollision +import com.google.gson.Gson +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class MixedObjectSourceTest { + @Test + fun namedArgumentsDefaultCopyAndDestructuringRetainExtensions() { + val original = MixedMap(knownField = "fixed") + original.additionalProperties["extra"] = NestedValue(wireValue = "nested") + val duplicate = original.copy() + val changed = original.copy(knownField = "changed") + val (known, extras) = duplicate + assertEquals("fixed", known) + assertSame(original.additionalProperties, extras) + assertEquals(original, duplicate) + assertEquals("changed", changed.knownField) + assertSame(extras, changed.additionalProperties) + assertEquals(Gson().toJsonTree(original), Gson().toJsonTree(duplicate)) + } + + @Test + fun defaultAndAuthoredStorageArgumentsKeepTheirSourceNames() { + assertTrue(OptionalKnown().additionalProperties.isEmpty()) + assertTrue(OptionalKnown(knownField = null).additionalProperties.isEmpty()) + val collision = StorageCollision(additionalProperties = "authored") + collision.additionalProperties2["extra"] = NestedValue(wireValue = "nested") + assertEquals("authored", collision.copy().additionalProperties) + assertSame(collision.additionalProperties2, collision.copy().additionalProperties2) + val supplied = MixedMap( + knownField = "fixed", + additionalProperties = linkedMapOf("extra" to NestedValue(wireValue = "nested")), + ) + assertEquals("nested", supplied.additionalProperties["extra"]?.wireValue) + } +} diff --git a/tests/generated-clients/jvm/kotlin/fixtures.txt b/tests/generated-clients/jvm/kotlin/fixtures.txt index c4ec237aa..ffdc38a58 100644 --- a/tests/generated-clients/jvm/kotlin/fixtures.txt +++ b/tests/generated-clients/jvm/kotlin/fixtures.txt @@ -7,3 +7,7 @@ union-helper-types valid/java-union-helper-types.yaml firstMatch union-helper-types-strict valid/java-union-helper-types.yaml strict dictionaries valid/kotlin-dictionaries.yaml dictionary-helper-types valid/kotlin-dictionary-helper-types.yaml +additional-properties valid/kotlin-additional-properties.yaml +additional-properties-strict valid/kotlin-additional-properties.yaml strict +additional-properties-raw valid/kotlin-additional-properties.yaml raw +additional-helper-types valid/kotlin-additional-helper-types.yaml diff --git a/tests/generated-clients/jvm/kotlin/support/additional-properties/AdditionalPropertiesContracts.java b/tests/generated-clients/jvm/kotlin/support/additional-properties/AdditionalPropertiesContracts.java new file mode 100644 index 000000000..40d1a81df --- /dev/null +++ b/tests/generated-clients/jvm/kotlin/support/additional-properties/AdditionalPropertiesContracts.java @@ -0,0 +1,294 @@ +import static org.junit.jupiter.api.Assertions.*; + +import com.example.sdk.models.*; +import com.example.sdk.apis.DefaultApi; +import com.example.sdk.apis.EchoMixedBadRequest; +import com.example.sdk.apis.EchoMixedException; +import com.example.sdk.runtime.ApiClient; +import com.google.gson.*; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; +import java.io.IOException; +import java.io.StringWriter; +import java.lang.reflect.Method; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Protocol; +import okhttp3.Response; +import okhttp3.ResponseBody; + +abstract class AdditionalPropertiesContracts { + final Gson gson = new Gson(); + + T roundTrip(String wire, Class type) { + T value = gson.fromJson(wire, type); + assertEquals(JsonParser.parseString(wire), gson.toJsonTree(value, type)); + assertEquals(JsonParser.parseString(wire), JsonParser.parseString(gson.toJson(value, type))); + return value; + } + + @Test void opaqueExtensionsKeepVerbatimKeysAndValues() { + roundTrip("{\"known_field\":\"fixed\",\"knownField\":\"shadow\",\"extra\":{\"nil\":null,\"large\":9007199254740993}}", OpaqueMap.class); + } + + @Test void typedExtensionsKeepTheirNestedWireNames() { + MixedMap value = roundTrip("{\"known_field\":\"fixed\",\"extra\":{\"wire_value\":\"nested\"}}", MixedMap.class); + assertEquals("nested", value.getAdditionalProperties().get("extra").getWireValue()); + } + + @Test void existingPureDictionaryCodecStillKeepsItsEntries() { + TypedMap value = roundTrip("{\"extra\":{\"wire_value\":\"nested\"},\"nil\":null}", TypedMap.class); + assertInstanceOf(NestedValue.class, value.getAdditionalProperties().get("extra")); + } + + @Test void oldJavaConstructorsAndIndependentDefaultsRemainUsable() { + MixedMap first = new MixedMap("fixed"); + MixedMap second = new MixedMap("fixed"); + first.getAdditionalProperties().put("extra", new NestedValue("new")); + assertEquals("{\"known_field\":\"fixed\",\"extra\":{\"wire_value\":\"new\"}}", gson.toJson(first)); + assertTrue(second.getAdditionalProperties().isEmpty()); + assertEquals("{\"known_field\":\"fixed\"}", gson.toJson(second)); + assertNotEquals(first, second); + assertTrue(new OptionalKnown(null).getAdditionalProperties().isEmpty()); + assertTrue(new OptionalKnown().getAdditionalProperties().isEmpty()); + NamedOpaque opaque = new NamedOpaque("ordinary", List.of("ordinary")); + assertEquals("ordinary", opaque.getKnownField()); + assertEquals("{\"known_field\":\"ordinary\",\"known_values\":[\"ordinary\"]}", gson.toJson(opaque)); + } + + @Test void componentsAndDefaultCopyKeepTypedExtensionStorage() throws Exception { + MixedMap value = roundTrip("{\"known_field\":\"fixed\",\"extra\":{\"wire_value\":\"nested\"}}", MixedMap.class); + assertEquals("fixed", value.component1()); + assertSame(value.getAdditionalProperties(), value.component2()); + // This is the JVM entrypoint used by Kotlin's ordinary value.copy(). + Method copy = MixedMap.class.getDeclaredMethod("copy$default", MixedMap.class, String.class, Map.class, int.class, Object.class); + MixedMap duplicate = (MixedMap) copy.invoke(null, value, null, null, 3, null); + assertEquals(value, duplicate); + assertSame(value.getAdditionalProperties(), duplicate.getAdditionalProperties()); + assertEquals(gson.toJsonTree(value), gson.toJsonTree(duplicate)); + MixedMap changed = value.copy("changed", value.getAdditionalProperties()); + assertEquals("changed", changed.component1()); + assertEquals("nested", changed.getAdditionalProperties().get("extra").getWireValue()); + } + + @Test void opaqueValuesKeepNullsLargeNumbersAndStorageLookingKeys() { + OpaqueMap value = roundTrip("{\"known_field\":\"fixed\",\"additionalProperties\":null,\"\":[true,null],\"large\":9007199254740993}", OpaqueMap.class); + assertTrue(value.getAdditionalProperties().get("additionalProperties").isJsonNull()); + assertEquals("9007199254740993", value.getAdditionalProperties().get("large").getAsString()); + } + + @Test void nullableDeepAndRawContainersRetainExtensionNulls() { + NullableValues nullable = roundTrip("{\"known_field\":\"fixed\",\"nil\":null,\"model\":{\"wire_value\":\"nested\"}}", NullableValues.class); + assertNull(nullable.getAdditionalProperties().get("nil")); + assertInstanceOf(NestedValue.class, nullable.getAdditionalProperties().get("model")); + DeepValues deep = roundTrip("{\"known_field\":\"fixed\",\"list\":[{\"keep_key\":{\"wire_value\":\"nested\"},\"nil\":null},null]}", DeepValues.class); + assertInstanceOf(NestedValue.class, deep.getAdditionalProperties().get("list").get(0).get("keep_key")); + roundTrip("{\"known_field\":\"fixed\",\"list\":[{\"nil\":null,\"large\":9007199254740993},null]}", RawContainers.class); + } + + @Test void scalarAndEnumExtensionValuesKeepTheirDeclaredTypes() { + LongValues longs = roundTrip("{\"known_field\":\"fixed\",\"large\":9007199254740993,\"negative\":-2}", LongValues.class); + assertEquals(9007199254740993L, longs.getAdditionalProperties().get("large")); + StatusValues status = roundTrip("{\"known_field\":\"fixed\",\"status\":\"active\"}", StatusValues.class); + assertInstanceOf(Status.class, status.getAdditionalProperties().get("status")); + } + + @Test void scalarFailuresUseTheOriginalNativeReader() { + for (String invalid : List.of("1.5", "2147483648", "-2147483649", "\"bad\"")) { + RuntimeException direct = assertThrows(RuntimeException.class, () -> gson.fromJson(invalid, Integer.class)); + RuntimeException mixed = assertThrows(RuntimeException.class, () -> gson.fromJson("{\"known_field\":\"fixed\",\"value\":" + invalid + "}", IntegerValues.class)); + assertEquals(direct.getClass(), mixed.getClass()); + } + } + + @Test void namedFieldsKeepNativeIntegerErrorsAndDuplicateHandling() { + for (String wire : List.of("{\"count\":1.5}", "{\"count\":2147483648}", "{\"count\":1.5,\"count\":2}")) { + RuntimeException plain = assertThrows(RuntimeException.class, () -> gson.fromJson(wire, PlainNumbers.class)); + RuntimeException mixed = assertThrows(RuntimeException.class, () -> gson.fromJson(wire, OpenNumbers.class)); + assertEquals(plain.getClass(), mixed.getClass()); + } + assertEquals(2, gson.fromJson("{\"count\":1,\"count\":2}", OpenNumbers.class).getCount()); + } + + @Test void ordinaryTreeCoercionRemainsDistinctFromStreamingCoercion() { + for (String wire : List.of("{\"count\":1.5}", "{\"count\":2147483648}")) { + JsonElement tree = JsonParser.parseString(wire); + assertEquals(gson.fromJson(tree, PlainNumbers.class).getCount(), gson.fromJson(tree, OpenNumbers.class).getCount()); + } + } + + @Test void ordinaryFieldNullOmissionAndSerializeNullsStayUnchanged() { + for (Gson codec : List.of(gson, new GsonBuilder().serializeNulls().create())) { + for (String wire : List.of("{}", "{\"count\":null}", "{\"count\":2,\"optional_count\":null,\"nullable_value\":null}")) { + assertEquals(codec.toJsonTree(codec.fromJson(wire, PlainNumbers.class)), codec.toJsonTree(codec.fromJson(wire, OpenNumbers.class))); + } + } + } + + @Test void extensionEntryNullsDoNotForceOrdinaryNestedFieldNulls() { + // Gson can construct the same missing-required-field value as before. + NestedValue nested = gson.fromJson("{}", NestedValue.class); + MixedMap value = new MixedMap("fixed"); + value.getAdditionalProperties().put("model", nested); + value.getAdditionalProperties().put("nil", null); + assertEquals("{\"known_field\":\"fixed\",\"model\":{},\"nil\":null}", gson.toJson(value)); + Gson explicit = new GsonBuilder().serializeNulls().create(); + assertEquals("{\"known_field\":\"fixed\",\"model\":{\"wire_value\":null},\"nil\":null}", explicit.toJson(value)); + } + + @Test void declaredFieldsCannotBeImpersonatedByExtensions() { + OptionalKnown value = new OptionalKnown(null); + value.getAdditionalProperties().put("known_field", new JsonPrimitive("collision")); + assertThrows(IllegalArgumentException.class, () -> gson.toJson(value)); + MixedMap mixed = new MixedMap("fixed"); + mixed.getAdditionalProperties().put("known_field", new NestedValue("bad")); + assertThrows(IllegalArgumentException.class, () -> gson.toJson(mixed)); + } + + @Test void authoredStorageNamesAreNotRenamed() { + StorageCollision value = roundTrip("{\"additionalProperties\":\"known\",\"additional_properties\":{\"wire_value\":\"extra\"}}", StorageCollision.class); + assertEquals("known", value.getAdditionalProperties()); + assertEquals("extra", value.getAdditionalProperties2().get("additional_properties").getWireValue()); + assertEquals("known", value.component1()); + } + + @Test void aliasesRetainKotlinTransparency() { + AliasValues value = roundTrip("{\"known_field\":\"fixed\",\"key\":{\"wire_value\":\"nested\"}}", AliasValues.class); + assertEquals("nested", value.getAdditionalProperties().get("key").getWireValue()); + } + + @Test void containingModelsRetainMixedAndPureDictionaryValues() { + Holder value = roundTrip("{\"mixed\":{\"known_field\":\"fixed\",\"extra\":{\"wire_value\":\"nested\"}},\"opaque\":{\"known_field\":\"fixed\",\"nil\":null},\"dictionaries\":[{\"extra\":{\"wire_value\":\"nested\"}}]}", Holder.class); + assertInstanceOf(TypedMap.class, value.getDictionaries().get(0)); + } + + @Test void recursiveValuesKeepTypedExtensions() { + Recursive value = roundTrip("{\"wire_value\":\"root\",\"child\":{\"wire_value\":\"leaf\"}}", Recursive.class); + assertInstanceOf(Recursive.class, value.getAdditionalProperties().get("child")); + } + + @Test void namedUnionBoundariesKeepTheirPoliciesAndFlatPayloads() { + roundTrip("{\"known_field\":\"fixed\",\"model\":{\"wire_value\":\"nested\"},\"scalar\":2}", Choices.class); + roundTrip("{\"known_field\":\"fixed\",\"extra\":{\"wire_value\":\"nested\"}}", MixedChoice.class); + } + + @Test void closedAndImplicitObjectsKeepTheirExistingBehavior() { + String wire = "{\"known_field\":\"fixed\",\"extra\":null}"; + assertEquals("{\"known_field\":\"fixed\"}", gson.toJson(gson.fromJson(wire, Closed.class))); + assertEquals("{\"known_field\":\"fixed\"}", gson.toJson(gson.fromJson(wire, Implicit.class))); + } + + @Test void rootNullAndWrongKindsUseNativeFailures() { + assertNull(gson.fromJson("null", MixedMap.class)); + assertEquals("null", gson.toJson(null, MixedMap.class)); + for (String wire : List.of("[]", "2", "\"bad\"")) { + assertThrows(JsonSyntaxException.class, () -> gson.fromJson(wire, MixedMap.class)); + } + } + + @Test void configuredValueAndNamedFieldAdaptersAreRetained() { + TypeAdapter custom = new TypeAdapter<>() { + public NestedValue read(JsonReader reader) throws IOException { + return new NestedValue(reader.nextString()); + } + public void write(JsonWriter writer, NestedValue value) throws IOException { + if (value == null) writer.nullValue(); else writer.value(value.getWireValue()); + } + }; + Gson codec = new GsonBuilder().registerTypeAdapter(NestedValue.class, custom).create(); + MixedMap mixed = codec.fromJson("{\"known_field\":\"fixed\",\"extra\":\"custom\"}", MixedMap.class); + assertEquals("custom", mixed.getAdditionalProperties().get("extra").getWireValue()); + assertEquals("{\"known_field\":\"fixed\",\"extra\":\"custom\"}", codec.toJson(mixed)); + OpenNumbers named = codec.fromJson("{\"count\":1,\"nullable_value\":\"named\"}", OpenNumbers.class); + assertEquals("named", named.getNullableValue().getWireValue()); + assertEquals("{\"count\":1,\"nullable_value\":\"named\"}", codec.toJson(named)); + } + + @Test void configuredFieldNamesRemainOwnedByTheOrdinaryDelegate() { + Gson codec = new GsonBuilder().setFieldNamingPolicy(FieldNamingPolicy.UPPER_CAMEL_CASE).create(); + OpenNumbers value = codec.fromJson("{\"Count\":2,\"extra\":null}", OpenNumbers.class); + assertEquals(2, value.getCount()); + assertEquals(java.util.Set.of("extra"), value.getAdditionalProperties().keySet()); + assertEquals("{\"Count\":2,\"extra\":null}", codec.toJson(value)); + value.getAdditionalProperties().put("Count", JsonNull.INSTANCE); + assertThrows(IllegalArgumentException.class, () -> codec.toJson(value)); + } + + @Test void configuredSpecialNumericOutputRetainsItsNativeRepresentation() { + Gson codec = new GsonBuilder().serializeSpecialFloatingPointValues().create(); + String wire = "{\"count\":2,\"weight\":\"NaN\"}"; + String plain = codec.toJson(codec.fromJson(wire, PlainNumbers.class)); + OpenNumbers mixed = codec.fromJson(wire, OpenNumbers.class); + assertEquals(plain, codec.toJson(mixed)); + assertEquals(plain, codec.toJson(codec.fromJson(JsonParser.parseString(wire), OpenNumbers.class))); + assertTrue(codec.toJsonTree(mixed).getAsJsonObject().get("weight").getAsJsonPrimitive().isNumber()); + mixed.getAdditionalProperties().put("nil", JsonNull.INSTANCE); + assertEquals(plain.substring(0, plain.length() - 1) + ",\"nil\":null}", codec.toJson(mixed)); + } + + @Test void extensionNullPolicyDoesNotLeakIntoOrdinarySiblingValues() throws IOException { + MixedMap value = new MixedMap("fixed"); + value.getAdditionalProperties().put("nil", null); + StringWriter buffer = new StringWriter(); + JsonWriter writer = new JsonWriter(buffer); + writer.setSerializeNulls(false); + writer.beginObject(); + writer.name("mixed"); + gson.getAdapter(MixedMap.class).write(writer, value); + assertFalse(writer.getSerializeNulls()); + writer.name("ordinary"); + writer.nullValue(); + writer.endObject(); + assertEquals("{\"mixed\":{\"known_field\":\"fixed\",\"nil\":null}}", buffer.toString()); + } + + @Test void writerSettingsAreRestoredOnExtensionWriteFailure() { + MixedMap value = new MixedMap("fixed"); + value.getAdditionalProperties().put("nil", null); + JsonWriter writer = new JsonWriter(new StringWriter()) { + @Override public JsonWriter nullValue() throws IOException { + throw new IOException("extension-write"); + } + }; + writer.setSerializeNulls(false); + IOException failure = assertThrows(IOException.class, () -> gson.getAdapter(MixedMap.class).write(writer, value)); + assertEquals("extension-write", failure.getMessage()); + assertFalse(writer.getSerializeNulls()); + } + + private DefaultApi api(int status, String reply, String request) { + OkHttpClient client = new OkHttpClient.Builder().addInterceptor(chain -> { + assertEquals("/mixed", chain.request().url().encodedPath()); + okio.Buffer buffer = new okio.Buffer(); + chain.request().body().writeTo(buffer); + assertEquals(JsonParser.parseString(request), JsonParser.parseString(buffer.readUtf8())); + return new Response.Builder().request(chain.request()).protocol(Protocol.HTTP_1_1) + .code(status).message("Mixed object").header("Content-Type", "application/json") + .body(ResponseBody.create(reply, MediaType.get("application/json"))).build(); + }).build(); + return new DefaultApi(new ApiClient("https://additional.test", client, null, Map.of())); + } + + @Test void httpRequestAndResponseRetainTypedMixedExtensions() { + String wire = "{\"known_field\":\"fixed\",\"extra\":{\"wire_value\":\"nested\"}}"; + MixedMap value = api(200, wire, wire).echoMixed(gson.fromJson(wire, MixedMap.class)).getStatus200(); + assertEquals("nested", value.getAdditionalProperties().get("extra").getWireValue()); + assertEquals(JsonParser.parseString(wire), gson.toJsonTree(value)); + } + + @Test void typedHttpErrorsKeepMixedExtensionsAndLazyNativeFailures() { + String request = "{\"known_field\":\"fixed\"}"; + String wire = "{\"known_field\":\"fixed\",\"extra\":{\"wire_value\":\"nested\"}}"; + EchoMixedException error = assertThrows(EchoMixedException.class, + () -> api(400, wire, request).echoMixed(new MixedMap("fixed"))); + EchoMixedBadRequest detail = assertInstanceOf(EchoMixedBadRequest.class, error.getDetail()); + assertEquals(JsonParser.parseString(wire), gson.toJsonTree(detail.body())); + EchoMixedException malformed = assertThrows(EchoMixedException.class, + () -> api(400, "{\"known_field\":\"fixed\",\"bad\":false}", request).echoMixed(new MixedMap("fixed"))); + EchoMixedBadRequest bad = assertInstanceOf(EchoMixedBadRequest.class, malformed.getDetail()); + assertThrows(JsonParseException.class, bad::body); + } +} diff --git a/tests/golden/kotlin/kotlin-okhttp/additional-helper-types/README.md.golden b/tests/golden/kotlin/kotlin-okhttp/additional-helper-types/README.md.golden new file mode 100644 index 000000000..8d57f0e77 --- /dev/null +++ b/tests/golden/kotlin/kotlin-okhttp/additional-helper-types/README.md.golden @@ -0,0 +1,7 @@ +# Kotlin additional helper types + +Generated Kotlin SDK. + +Version: `1.0.0` + +Generated by [openapi-nexus](https://github.com/rust-codegen-group/openapi-nexus) for `kotlin-additional-helper-types`. diff --git a/tests/golden/kotlin/kotlin-okhttp/additional-helper-types/build.gradle.kts.golden b/tests/golden/kotlin/kotlin-okhttp/additional-helper-types/build.gradle.kts.golden new file mode 100644 index 000000000..093728ca0 --- /dev/null +++ b/tests/golden/kotlin/kotlin-okhttp/additional-helper-types/build.gradle.kts.golden @@ -0,0 +1,27 @@ +plugins { + kotlin("jvm") version "2.2.21" +} + +group = "com.example.sdk" +version = "1.0.0" + +repositories { + mavenCentral() +} + +dependencies { + implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("com.google.code.gson:gson:2.11.0") +} + +kotlin { + jvmToolchain(17) +} + +sourceSets { + main { + kotlin { + srcDirs("apis", "models", "runtime") + } + } +} diff --git a/tests/golden/kotlin/kotlin-okhttp/additional-helper-types/models/Gson.kt.golden b/tests/golden/kotlin/kotlin-okhttp/additional-helper-types/models/Gson.kt.golden new file mode 100644 index 000000000..46be23b37 --- /dev/null +++ b/tests/golden/kotlin/kotlin-okhttp/additional-helper-types/models/Gson.kt.golden @@ -0,0 +1,47 @@ +// Code generated by openapi-nexus. DO NOT EDIT. +// +// Kotlin additional helper types — 1.0.0 + +package com.example.sdk.models + +import com.google.gson.annotations.SerializedName + +@com.google.gson.annotations.JsonAdapter(GsonAdditionalPropertiesAdapterFactory2::class, nullSafe = false) +data class Gson(@field:SerializedName("known_field") + val knownField: String, @field:kotlin.jvm.Transient + var additionalProperties: kotlin.collections.MutableMap = linkedMapOf()) { + constructor(knownField: kotlin.String) : this(knownField, linkedMapOf()) { + + } +} + +private class GsonAdditionalPropertiesAdapterFactory2 : com.example.sdk.runtime.AdditionalPropertiesAdapterFactory { + constructor() : super(com.example.sdk.models.Gson::class.java, object: com.google.gson.reflect.TypeToken() {}, "known_field") { + + } + + override fun properties(value: com.example.sdk.models.Gson): kotlin.collections.MutableMap { + return value.additionalProperties + } + + override fun setProperties(value: com.example.sdk.models.Gson, + entries: kotlin.collections.MutableMap) { + value.additionalProperties = entries + } + + override fun encode(gson: com.google.gson.Gson, + value: kotlin.Long?): com.google.gson.JsonElement { + return additionalValue0(gson, value) + } + + private fun additionalValue0(gson: com.google.gson.Gson, + value: kotlin.Long?): com.google.gson.JsonElement { + if (value == null) { + return com.google.gson.JsonNull.INSTANCE + } + return gson.toJsonTree(value, object: com.google.gson.reflect.TypeToken() {}.type) + } +} diff --git a/tests/golden/kotlin/kotlin-okhttp/additional-helper-types/models/GsonAdditionalPropertiesAdapterFactory.kt.golden b/tests/golden/kotlin/kotlin-okhttp/additional-helper-types/models/GsonAdditionalPropertiesAdapterFactory.kt.golden new file mode 100644 index 000000000..92bcec3f9 --- /dev/null +++ b/tests/golden/kotlin/kotlin-okhttp/additional-helper-types/models/GsonAdditionalPropertiesAdapterFactory.kt.golden @@ -0,0 +1,8 @@ +// Code generated by openapi-nexus. DO NOT EDIT. +// +// Kotlin additional helper types — 1.0.0 + +package com.example.sdk.models + +data class GsonAdditionalPropertiesAdapterFactory(val label: String) { +} diff --git a/tests/golden/kotlin/kotlin-okhttp/additional-helper-types/models/JsonElement.kt.golden b/tests/golden/kotlin/kotlin-okhttp/additional-helper-types/models/JsonElement.kt.golden new file mode 100644 index 000000000..68e48ed32 --- /dev/null +++ b/tests/golden/kotlin/kotlin-okhttp/additional-helper-types/models/JsonElement.kt.golden @@ -0,0 +1,47 @@ +// Code generated by openapi-nexus. DO NOT EDIT. +// +// Kotlin additional helper types — 1.0.0 + +package com.example.sdk.models + +import com.google.gson.annotations.SerializedName + +@com.google.gson.annotations.JsonAdapter(JsonElementAdditionalPropertiesAdapterFactory::class, nullSafe = false) +data class JsonElement(@field:SerializedName("known_field") + val knownField: String, @field:kotlin.jvm.Transient + var additionalProperties: kotlin.collections.MutableMap = linkedMapOf()) { + constructor(knownField: kotlin.String) : this(knownField, linkedMapOf()) { + + } +} + +private class JsonElementAdditionalPropertiesAdapterFactory : com.example.sdk.runtime.AdditionalPropertiesAdapterFactory { + constructor() : super(com.example.sdk.models.JsonElement::class.java, object: com.google.gson.reflect.TypeToken() {}, "known_field") { + + } + + override fun properties(value: com.example.sdk.models.JsonElement): kotlin.collections.MutableMap { + return value.additionalProperties + } + + override fun setProperties(value: com.example.sdk.models.JsonElement, + entries: kotlin.collections.MutableMap) { + value.additionalProperties = entries + } + + override fun encode(gson: com.google.gson.Gson, + value: com.google.gson.JsonElement?): com.google.gson.JsonElement { + return additionalValue0(gson, value) + } + + private fun additionalValue0(gson: com.google.gson.Gson, + value: com.google.gson.JsonElement?): com.google.gson.JsonElement { + if (value == null) { + return com.google.gson.JsonNull.INSTANCE + } + return value + } +} diff --git a/tests/golden/kotlin/kotlin-okhttp/additional-helper-types/models/Map.kt.golden b/tests/golden/kotlin/kotlin-okhttp/additional-helper-types/models/Map.kt.golden new file mode 100644 index 000000000..4b88a3356 --- /dev/null +++ b/tests/golden/kotlin/kotlin-okhttp/additional-helper-types/models/Map.kt.golden @@ -0,0 +1,80 @@ +// Code generated by openapi-nexus. DO NOT EDIT. +// +// Kotlin additional helper types — 1.0.0 + +package com.example.sdk.models + +import com.google.gson.annotations.SerializedName + +@com.google.gson.annotations.JsonAdapter(MapAdditionalPropertiesAdapterFactory::class, nullSafe = false) +data class Map(@field:SerializedName("known_field") + val knownField: String, @field:kotlin.jvm.Transient + var additionalProperties: kotlin.collections.MutableMap>> = linkedMapOf()) { + constructor(knownField: kotlin.String) : this(knownField, linkedMapOf()) { + + } +} + +private class MapAdditionalPropertiesAdapterFactory : com.example.sdk.runtime.AdditionalPropertiesAdapterFactory>> { + constructor() : super(com.example.sdk.models.Map::class.java, object: com.google.gson.reflect.TypeToken>>() {}, "known_field") { + + } + + override fun properties(value: com.example.sdk.models.Map): kotlin.collections.MutableMap>> { + return value.additionalProperties + } + + override fun setProperties(value: com.example.sdk.models.Map, + entries: kotlin.collections.MutableMap>>) { + value.additionalProperties = entries + } + + override fun encode(gson: com.google.gson.Gson, + value: kotlin.collections.List>?): com.google.gson.JsonElement { + return additionalValue2(gson, value) + } + + private fun additionalValue0(gson: com.google.gson.Gson, + value: com.example.sdk.models.JsonElement?): com.google.gson.JsonElement { + if (value == null) { + return com.google.gson.JsonNull.INSTANCE + } + return gson.toJsonTree(value, object: com.google.gson.reflect.TypeToken() {}.type) + } + + private fun additionalValue1(gson: com.google.gson.Gson, + value: kotlin.collections.Map?): com.google.gson.JsonElement { + if (value == null) { + return com.google.gson.JsonNull.INSTANCE + } + val result = com.google.gson.JsonObject() + for (entry in value.entries) { + result.add(entry.key, additionalValue0(gson, entry.value)) + } + return result + } + + private fun additionalValue2(gson: com.google.gson.Gson, + value: kotlin.collections.List>?): com.google.gson.JsonElement { + if (value == null) { + return com.google.gson.JsonNull.INSTANCE + } + val result = com.google.gson.JsonArray() + for (item in value) { + result.add(additionalValue1(gson, item)) + } + return result + } +} diff --git a/tests/golden/kotlin/kotlin-okhttp/additional-helper-types/runtime/AdditionalPropertiesAdapterFactory.kt.golden b/tests/golden/kotlin/kotlin-okhttp/additional-helper-types/runtime/AdditionalPropertiesAdapterFactory.kt.golden new file mode 100644 index 000000000..8654dfb26 --- /dev/null +++ b/tests/golden/kotlin/kotlin-okhttp/additional-helper-types/runtime/AdditionalPropertiesAdapterFactory.kt.golden @@ -0,0 +1,136 @@ +// Code generated by openapi-nexus. DO NOT EDIT. +// +// Kotlin additional helper types — 1.0.0 + +package com.example.sdk.runtime + +import com.google.gson.Gson +import com.google.gson.JsonElement +import com.google.gson.JsonParser +import com.google.gson.JsonSyntaxException +import com.google.gson.TypeAdapter +import com.google.gson.TypeAdapterFactory +import com.google.gson.annotations.SerializedName +import com.google.gson.internal.bind.JsonTreeReader +import com.google.gson.internal.bind.JsonTreeWriter +import com.google.gson.reflect.TypeToken +import com.google.gson.stream.JsonReader +import com.google.gson.stream.JsonToken +import com.google.gson.stream.JsonWriter +import java.io.StringReader +import java.io.StringWriter +import java.lang.reflect.Modifier + +/** Internal flat-object support; named fields retain their configured Gson delegate. */ +abstract class AdditionalPropertiesAdapterFactory( + private val modelClass: Class, + private val valueType: TypeToken, + vararg declaredNames: String, +) : TypeAdapterFactory { + private val declaredNames = declaredNames.toSet() + + protected abstract fun properties(value: M): MutableMap + protected abstract fun setProperties(value: M, entries: MutableMap) + protected abstract fun encode(gson: Gson, value: V?): JsonElement + + override fun create(gson: Gson, type: TypeToken): TypeAdapter? { + val modelType: TypeToken = TypeToken.get(modelClass) + if (type.rawType != modelClass) return null + val delegate: TypeAdapter = gson.getDelegateAdapter(this, modelType) + val values: TypeAdapter = gson.getAdapter(valueType) + val trees = gson.getAdapter(JsonElement::class.java) + val known = declaredNames.toMutableSet() + for (field in modelClass.declaredFields) { + if (Modifier.isStatic(field.modifiers) || Modifier.isTransient(field.modifiers)) continue + val annotation = field.getAnnotation(SerializedName::class.java) + if (annotation == null) { + known.add(gson.fieldNamingStrategy().translateName(field)) + } else { + known.add(annotation.value) + known.addAll(annotation.alternate) + } + } + val adapter = object : TypeAdapter() { + override fun read(reader: JsonReader): M? { + if (reader.peek() == JsonToken.NULL) { + reader.nextNull() + return null + } + if (reader.peek() != JsonToken.BEGIN_OBJECT) { + throw JsonSyntaxException("Expected ${modelClass.simpleName} object") + } + val extras = linkedMapOf() + val buffer = StringWriter() + val named = JsonWriter(buffer) + named.serializeNulls = true + named.strictness = reader.strictness + named.beginObject() + reader.beginObject() + while (reader.hasNext()) { + val key = reader.nextName() + if (key in known) { + named.name(key) + trees.write(named, trees.read(reader)) + } else { + // Keep the original reader's native value conversion behavior. + extras[key] = values.read(reader) + } + } + reader.endObject() + named.endObject() + // Tree and streaming readers deliberately retain their own coercion. + val replay = if (reader is JsonTreeReader) { + JsonTreeReader(JsonParser.parseString(buffer.toString())) + } else { + JsonReader(StringReader(buffer.toString())) + } + replay.strictness = reader.strictness + val result = delegate.read(replay) + if (result != null) { + // Gson may allocate without running the Kotlin constructor. + setProperties(result, extras) + } + return result + } + + override fun write(writer: JsonWriter, value: M?) { + if (value == null) { + writer.nullValue() + return + } + val extras = properties(value) + for (key in extras.keys) { + require(key !in known) { "Additional property collides with declared wire field: $key" } + } + // Native trees preserve configured numeric values such as NaN. + val named = JsonTreeWriter() + named.serializeNulls = writer.serializeNulls + named.isHtmlSafe = writer.isHtmlSafe + named.strictness = writer.strictness + delegate.write(named, value) + val fields = named.get().asJsonObject + writer.beginObject() + for ((key, tree) in fields.entrySet()) { + writer.name(key) + trees.write(writer, tree) + } + for ((key, item) in extras) { + require(!fields.has(key)) { "Additional property collides with declared wire field: $key" } + val tree = encode(gson, item) + writer.name(key) + val serializeNulls = writer.serializeNulls + writer.serializeNulls = true + try { + trees.write(writer, tree) + } finally { + writer.serializeNulls = serializeNulls + } + } + writer.endObject() + } + } + // The checked raw-type identity proves this cast for non-generic models. + @Suppress("UNCHECKED_CAST") + return adapter as TypeAdapter + } +} diff --git a/tests/golden/kotlin/kotlin-okhttp/additional-helper-types/runtime/ApiClient.kt.golden b/tests/golden/kotlin/kotlin-okhttp/additional-helper-types/runtime/ApiClient.kt.golden new file mode 100644 index 000000000..b66eb2133 --- /dev/null +++ b/tests/golden/kotlin/kotlin-okhttp/additional-helper-types/runtime/ApiClient.kt.golden @@ -0,0 +1,61 @@ +// Code generated by openapi-nexus. DO NOT EDIT. +// +// Kotlin additional helper types — 1.0.0 + +package com.example.sdk.runtime + +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response + +class ApiClient( + private val baseUrl: String, + private val client: OkHttpClient = OkHttpClient(), + private val authenticator: Authenticator? = null, + private val defaultHeaders: Map = emptyMap(), +) { + fun newRequest( + method: String, + path: String, + query: Map? = null, + body: String? = null, + ): Request { + return buildRequest(method, path, query, body?.toRequestBody("application/json".toMediaType())) + } + + fun newRequestWithBody( + method: String, + path: String, + query: Map? = null, + body: okhttp3.RequestBody?, + ): Request { + return buildRequest(method, path, query, body) + } + + private fun buildRequest( + method: String, + path: String, + query: Map? = null, + body: okhttp3.RequestBody?, + ): Request { + val urlBuilder: HttpUrl.Builder = (baseUrl.trimEnd('/') + path).toHttpUrl().newBuilder() + query?.forEach { (k, v) -> urlBuilder.addQueryParameter(k, v) } + + val builder = Request.Builder() + .url(urlBuilder.build()) + .method(method, body) + + defaultHeaders.forEach { (k, v) -> builder.header(k, v) } + authenticator?.authenticate(builder) + + return builder.build() + } + + fun execute(request: Request): Response { + return client.newCall(request).execute() + } +} diff --git a/tests/golden/kotlin/kotlin-okhttp/additional-helper-types/runtime/ApiException.kt.golden b/tests/golden/kotlin/kotlin-okhttp/additional-helper-types/runtime/ApiException.kt.golden new file mode 100644 index 000000000..ce8020465 --- /dev/null +++ b/tests/golden/kotlin/kotlin-okhttp/additional-helper-types/runtime/ApiException.kt.golden @@ -0,0 +1,11 @@ +// Code generated by openapi-nexus. DO NOT EDIT. +// +// Kotlin additional helper types — 1.0.0 + +package com.example.sdk.runtime + +open class ApiException( + val statusCode: Int, + val status: String, + val body: String, +) : RuntimeException("API error $statusCode: $status") diff --git a/tests/golden/kotlin/kotlin-okhttp/additional-helper-types/runtime/Auth.kt.golden b/tests/golden/kotlin/kotlin-okhttp/additional-helper-types/runtime/Auth.kt.golden new file mode 100644 index 000000000..f1af56cae --- /dev/null +++ b/tests/golden/kotlin/kotlin-okhttp/additional-helper-types/runtime/Auth.kt.golden @@ -0,0 +1,70 @@ +// Code generated by openapi-nexus. DO NOT EDIT. +// +// Kotlin additional helper types — 1.0.0 + +package com.example.sdk.runtime + +import okhttp3.Request + +interface Authenticator { + fun authenticate(builder: Request.Builder) +} + +class BearerAuth : Authenticator { + private val token: String? + private val tokenProvider: (() -> String)? + + constructor(token: String) { + this.token = token + this.tokenProvider = null + } + + constructor(tokenProvider: () -> String) { + this.token = null + this.tokenProvider = tokenProvider + } + + override fun authenticate(builder: Request.Builder) { + val t = tokenProvider?.invoke() ?: token!! + builder.header("Authorization", "Bearer $t") + } +} + +class ApiKeyAuth : Authenticator { + private val key: String? + private val keyProvider: (() -> String)? + private val name: String + private val location: ApiKeyLocation + + constructor(key: String, name: String, location: ApiKeyLocation) { + this.key = key + this.keyProvider = null + this.name = name + this.location = location + } + + constructor(keyProvider: () -> String, name: String, location: ApiKeyLocation) { + this.key = null + this.keyProvider = keyProvider + this.name = name + this.location = location + } + + override fun authenticate(builder: Request.Builder) { + val k = keyProvider?.invoke() ?: key!! + when (location) { + ApiKeyLocation.HEADER -> builder.header(name, k) + ApiKeyLocation.QUERY -> { + val url = builder.build().url.newBuilder() + .addQueryParameter(name, k) + .build() + builder.url(url) + } + } + } +} + +enum class ApiKeyLocation { + HEADER, + QUERY, +} diff --git a/tests/golden/kotlin/kotlin-okhttp/comprehensive-schemas/models/ObjectWithAdditionalPropertiesTrue.kt.golden b/tests/golden/kotlin/kotlin-okhttp/comprehensive-schemas/models/ObjectWithAdditionalPropertiesTrue.kt.golden index ed17bbe12..c381f29d5 100644 --- a/tests/golden/kotlin/kotlin-okhttp/comprehensive-schemas/models/ObjectWithAdditionalPropertiesTrue.kt.golden +++ b/tests/golden/kotlin/kotlin-okhttp/comprehensive-schemas/models/ObjectWithAdditionalPropertiesTrue.kt.golden @@ -8,5 +8,41 @@ package com.example.sdk.models /** * Object allowing any additional properties */ -data class ObjectWithAdditionalPropertiesTrue(val name: String) { +@com.google.gson.annotations.JsonAdapter(ObjectWithAdditionalPropertiesTrueAdditionalPropertiesAdapterFactory::class, nullSafe = false) +data class ObjectWithAdditionalPropertiesTrue(val name: String, @field:kotlin.jvm.Transient + var additionalProperties: kotlin.collections.MutableMap = linkedMapOf()) { + constructor(name: kotlin.String) : this(name, linkedMapOf()) { + + } +} + +private class ObjectWithAdditionalPropertiesTrueAdditionalPropertiesAdapterFactory : com.example.sdk.runtime.AdditionalPropertiesAdapterFactory { + constructor() : super(com.example.sdk.models.ObjectWithAdditionalPropertiesTrue::class.java, object: com.google.gson.reflect.TypeToken() {}, "name") { + + } + + override fun properties(value: com.example.sdk.models.ObjectWithAdditionalPropertiesTrue): kotlin.collections.MutableMap { + return value.additionalProperties + } + + override fun setProperties(value: com.example.sdk.models.ObjectWithAdditionalPropertiesTrue, + entries: kotlin.collections.MutableMap) { + value.additionalProperties = entries + } + + override fun encode(gson: com.google.gson.Gson, + value: com.google.gson.JsonElement?): com.google.gson.JsonElement { + return additionalValue0(gson, value) + } + + private fun additionalValue0(gson: com.google.gson.Gson, + value: com.google.gson.JsonElement?): com.google.gson.JsonElement { + if (value == null) { + return com.google.gson.JsonNull.INSTANCE + } + return value + } } diff --git a/tests/golden/kotlin/kotlin-okhttp/comprehensive-schemas/models/ObjectWithTypedAdditionalProperties.kt.golden b/tests/golden/kotlin/kotlin-okhttp/comprehensive-schemas/models/ObjectWithTypedAdditionalProperties.kt.golden index ba834f41e..b37394c0d 100644 --- a/tests/golden/kotlin/kotlin-okhttp/comprehensive-schemas/models/ObjectWithTypedAdditionalProperties.kt.golden +++ b/tests/golden/kotlin/kotlin-okhttp/comprehensive-schemas/models/ObjectWithTypedAdditionalProperties.kt.golden @@ -8,5 +8,41 @@ package com.example.sdk.models /** * Object with typed additional properties */ -data class ObjectWithTypedAdditionalProperties(val name: String) { +@com.google.gson.annotations.JsonAdapter(ObjectWithTypedAdditionalPropertiesAdditionalPropertiesAdapterFactory::class, nullSafe = false) +data class ObjectWithTypedAdditionalProperties(val name: String, @field:kotlin.jvm.Transient + var additionalProperties: kotlin.collections.MutableMap = linkedMapOf()) { + constructor(name: kotlin.String) : this(name, linkedMapOf()) { + + } +} + +private class ObjectWithTypedAdditionalPropertiesAdditionalPropertiesAdapterFactory : com.example.sdk.runtime.AdditionalPropertiesAdapterFactory { + constructor() : super(com.example.sdk.models.ObjectWithTypedAdditionalProperties::class.java, object: com.google.gson.reflect.TypeToken() {}, "name") { + + } + + override fun properties(value: com.example.sdk.models.ObjectWithTypedAdditionalProperties): kotlin.collections.MutableMap { + return value.additionalProperties + } + + override fun setProperties(value: com.example.sdk.models.ObjectWithTypedAdditionalProperties, + entries: kotlin.collections.MutableMap) { + value.additionalProperties = entries + } + + override fun encode(gson: com.google.gson.Gson, + value: kotlin.Double?): com.google.gson.JsonElement { + return additionalValue0(gson, value) + } + + private fun additionalValue0(gson: com.google.gson.Gson, + value: kotlin.Double?): com.google.gson.JsonElement { + if (value == null) { + return com.google.gson.JsonNull.INSTANCE + } + return gson.toJsonTree(value, object: com.google.gson.reflect.TypeToken() {}.type) + } } diff --git a/tests/golden/kotlin/kotlin-okhttp/comprehensive-schemas/runtime/AdditionalPropertiesAdapterFactory.kt.golden b/tests/golden/kotlin/kotlin-okhttp/comprehensive-schemas/runtime/AdditionalPropertiesAdapterFactory.kt.golden new file mode 100644 index 000000000..59ceca59b --- /dev/null +++ b/tests/golden/kotlin/kotlin-okhttp/comprehensive-schemas/runtime/AdditionalPropertiesAdapterFactory.kt.golden @@ -0,0 +1,137 @@ +// Code generated by openapi-nexus. DO NOT EDIT. +// +// Comprehensive Schema Types Example — 1.0.0 +// Comprehensive test for all OpenAPI v3.1.2 schema types + +package com.example.sdk.runtime + +import com.google.gson.Gson +import com.google.gson.JsonElement +import com.google.gson.JsonParser +import com.google.gson.JsonSyntaxException +import com.google.gson.TypeAdapter +import com.google.gson.TypeAdapterFactory +import com.google.gson.annotations.SerializedName +import com.google.gson.internal.bind.JsonTreeReader +import com.google.gson.internal.bind.JsonTreeWriter +import com.google.gson.reflect.TypeToken +import com.google.gson.stream.JsonReader +import com.google.gson.stream.JsonToken +import com.google.gson.stream.JsonWriter +import java.io.StringReader +import java.io.StringWriter +import java.lang.reflect.Modifier + +/** Internal flat-object support; named fields retain their configured Gson delegate. */ +abstract class AdditionalPropertiesAdapterFactory( + private val modelClass: Class, + private val valueType: TypeToken, + vararg declaredNames: String, +) : TypeAdapterFactory { + private val declaredNames = declaredNames.toSet() + + protected abstract fun properties(value: M): MutableMap + protected abstract fun setProperties(value: M, entries: MutableMap) + protected abstract fun encode(gson: Gson, value: V?): JsonElement + + override fun create(gson: Gson, type: TypeToken): TypeAdapter? { + val modelType: TypeToken = TypeToken.get(modelClass) + if (type.rawType != modelClass) return null + val delegate: TypeAdapter = gson.getDelegateAdapter(this, modelType) + val values: TypeAdapter = gson.getAdapter(valueType) + val trees = gson.getAdapter(JsonElement::class.java) + val known = declaredNames.toMutableSet() + for (field in modelClass.declaredFields) { + if (Modifier.isStatic(field.modifiers) || Modifier.isTransient(field.modifiers)) continue + val annotation = field.getAnnotation(SerializedName::class.java) + if (annotation == null) { + known.add(gson.fieldNamingStrategy().translateName(field)) + } else { + known.add(annotation.value) + known.addAll(annotation.alternate) + } + } + val adapter = object : TypeAdapter() { + override fun read(reader: JsonReader): M? { + if (reader.peek() == JsonToken.NULL) { + reader.nextNull() + return null + } + if (reader.peek() != JsonToken.BEGIN_OBJECT) { + throw JsonSyntaxException("Expected ${modelClass.simpleName} object") + } + val extras = linkedMapOf() + val buffer = StringWriter() + val named = JsonWriter(buffer) + named.serializeNulls = true + named.strictness = reader.strictness + named.beginObject() + reader.beginObject() + while (reader.hasNext()) { + val key = reader.nextName() + if (key in known) { + named.name(key) + trees.write(named, trees.read(reader)) + } else { + // Keep the original reader's native value conversion behavior. + extras[key] = values.read(reader) + } + } + reader.endObject() + named.endObject() + // Tree and streaming readers deliberately retain their own coercion. + val replay = if (reader is JsonTreeReader) { + JsonTreeReader(JsonParser.parseString(buffer.toString())) + } else { + JsonReader(StringReader(buffer.toString())) + } + replay.strictness = reader.strictness + val result = delegate.read(replay) + if (result != null) { + // Gson may allocate without running the Kotlin constructor. + setProperties(result, extras) + } + return result + } + + override fun write(writer: JsonWriter, value: M?) { + if (value == null) { + writer.nullValue() + return + } + val extras = properties(value) + for (key in extras.keys) { + require(key !in known) { "Additional property collides with declared wire field: $key" } + } + // Native trees preserve configured numeric values such as NaN. + val named = JsonTreeWriter() + named.serializeNulls = writer.serializeNulls + named.isHtmlSafe = writer.isHtmlSafe + named.strictness = writer.strictness + delegate.write(named, value) + val fields = named.get().asJsonObject + writer.beginObject() + for ((key, tree) in fields.entrySet()) { + writer.name(key) + trees.write(writer, tree) + } + for ((key, item) in extras) { + require(!fields.has(key)) { "Additional property collides with declared wire field: $key" } + val tree = encode(gson, item) + writer.name(key) + val serializeNulls = writer.serializeNulls + writer.serializeNulls = true + try { + trees.write(writer, tree) + } finally { + writer.serializeNulls = serializeNulls + } + } + writer.endObject() + } + } + // The checked raw-type identity proves this cast for non-generic models. + @Suppress("UNCHECKED_CAST") + return adapter as TypeAdapter + } +} diff --git a/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/README.md.golden b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/README.md.golden new file mode 100644 index 000000000..441bc0039 --- /dev/null +++ b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/README.md.golden @@ -0,0 +1,7 @@ +# Kotlin additional properties + +Generated Kotlin SDK. + +Version: `1.0.0` + +Generated by [openapi-nexus](https://github.com/rust-codegen-group/openapi-nexus) for `kotlin-additional-properties`. diff --git a/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/apis/DefaultApi.kt.golden b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/apis/DefaultApi.kt.golden new file mode 100644 index 000000000..e732494fc --- /dev/null +++ b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/apis/DefaultApi.kt.golden @@ -0,0 +1,105 @@ +// Code generated by openapi-nexus. DO NOT EDIT. +// +// Kotlin additional properties — 1.0.0 + +package com.example.sdk.apis + +import com.example.sdk.models.* +import com.example.sdk.runtime.ApiClient +import com.example.sdk.runtime.ApiException +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response + +/** + * EchoMixedResponse carries the response from echoMixed. + */ +data class EchoMixedResponse(val statusCode: Int, val raw: Response, + val status200: MixedMap? = null) { +} + +sealed interface EchoMixedExceptionDetail { + val statusCode: Int + val headers: okhttp3.Headers + fun rawBody(): ByteArray +} + +class EchoMixedException( + val detail: EchoMixedExceptionDetail, + statusCode: Int, + status: String, + body: String, + val headers: okhttp3.Headers, + rawBodyBytes: ByteArray, +) : ApiException(statusCode, status, body) { + private val rawBodyBytes: ByteArray = rawBodyBytes.copyOf() + fun rawBody(): ByteArray = this.rawBodyBytes.copyOf() +} + +class EchoMixedBadRequest( + override val statusCode: Int, + override val headers: okhttp3.Headers, + rawBodyBytes: ByteArray, +) : EchoMixedExceptionDetail { + private val rawBodyBytes: ByteArray = rawBodyBytes.copyOf() + override fun rawBody(): ByteArray = this.rawBodyBytes.copyOf() + private fun textBody(): String { + val charset = headers["Content-Type"]?.toMediaTypeOrNull()?.charset(Charsets.UTF_8) ?: Charsets.UTF_8 + return rawBodyBytes.toString(charset) + } + fun body(): MixedMap = Gson().fromJson(textBody().ifEmpty { "null" }, object : TypeToken() {}.type) +} + +class EchoMixedUnexpected( + override val statusCode: Int, + override val headers: okhttp3.Headers, + rawBodyBytes: ByteArray, +) : EchoMixedExceptionDetail { + private val rawBodyBytes: ByteArray = rawBodyBytes.copyOf() + override fun rawBody(): ByteArray = this.rawBodyBytes.copyOf() + fun body(): ByteArray = rawBody() +} + +/** + * DefaultApi groups operations under the default tag. + */ +class DefaultApi(private val client: ApiClient) { + private val gson: Gson = Gson() + + /** + * echoMixed POST /mixed. + */ + fun echoMixed(body: MixedMap): EchoMixedResponse { + val path = "/mixed" + val jsonBody = gson.toJson(body) + val requestBody = jsonBody.toRequestBody("application/json".toMediaType()) + val request = client.newRequestWithBody("POST", path, null, requestBody) + val response = client.execute(request) + + if (!response.isSuccessful) { + val responseBody = response.body + val responseCharset = responseBody?.contentType()?.charset(Charsets.UTF_8) ?: Charsets.UTF_8 + val responseBytes = responseBody?.bytes() ?: ByteArray(0) + val responseText = responseBytes.toString(responseCharset) + val detail: EchoMixedExceptionDetail = when (response.code) { + 400 -> EchoMixedBadRequest(statusCode = response.code, headers = response.headers, rawBodyBytes = responseBytes) + else -> { + EchoMixedUnexpected(statusCode = response.code, headers = response.headers, rawBodyBytes = responseBytes) + } + } + throw EchoMixedException(detail = detail, statusCode = response.code, status = response.message, body = responseText, headers = response.headers, rawBodyBytes = responseBytes) + } + + val responseBytes = response.body?.bytes() ?: ByteArray(0) + val responseText = responseBytes.toString(Charsets.UTF_8) + var status200: MixedMap? = null + + if (response.code == 200) { + status200 = gson.fromJson(responseText.ifEmpty { "null" }, object : TypeToken() {}.type) + } + return EchoMixedResponse(statusCode = response.code, raw = response, status200 = status200) + } +} diff --git a/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/build.gradle.kts.golden b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/build.gradle.kts.golden new file mode 100644 index 000000000..093728ca0 --- /dev/null +++ b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/build.gradle.kts.golden @@ -0,0 +1,27 @@ +plugins { + kotlin("jvm") version "2.2.21" +} + +group = "com.example.sdk" +version = "1.0.0" + +repositories { + mavenCentral() +} + +dependencies { + implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("com.google.code.gson:gson:2.11.0") +} + +kotlin { + jvmToolchain(17) +} + +sourceSets { + main { + kotlin { + srcDirs("apis", "models", "runtime") + } + } +} diff --git a/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/AliasValues.kt.golden b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/AliasValues.kt.golden new file mode 100644 index 000000000..d4bc8f239 --- /dev/null +++ b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/AliasValues.kt.golden @@ -0,0 +1,47 @@ +// Code generated by openapi-nexus. DO NOT EDIT. +// +// Kotlin additional properties — 1.0.0 + +package com.example.sdk.models + +import com.google.gson.annotations.SerializedName + +@com.google.gson.annotations.JsonAdapter(AliasValuesAdditionalPropertiesAdapterFactory::class, nullSafe = false) +data class AliasValues(@field:SerializedName("known_field") + val knownField: String, @field:kotlin.jvm.Transient + var additionalProperties: kotlin.collections.MutableMap = linkedMapOf()) { + constructor(knownField: kotlin.String) : this(knownField, linkedMapOf()) { + + } +} + +private class AliasValuesAdditionalPropertiesAdapterFactory : com.example.sdk.runtime.AdditionalPropertiesAdapterFactory { + constructor() : super(com.example.sdk.models.AliasValues::class.java, object: com.google.gson.reflect.TypeToken() {}, "known_field") { + + } + + override fun properties(value: com.example.sdk.models.AliasValues): kotlin.collections.MutableMap { + return value.additionalProperties + } + + override fun setProperties(value: com.example.sdk.models.AliasValues, + entries: kotlin.collections.MutableMap) { + value.additionalProperties = entries + } + + override fun encode(gson: com.google.gson.Gson, + value: com.example.sdk.models.ValueAlias?): com.google.gson.JsonElement { + return additionalValue0(gson, value) + } + + private fun additionalValue0(gson: com.google.gson.Gson, + value: com.example.sdk.models.ValueAlias?): com.google.gson.JsonElement { + if (value == null) { + return com.google.gson.JsonNull.INSTANCE + } + return gson.toJsonTree(value, object: com.google.gson.reflect.TypeToken() {}.type) + } +} diff --git a/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/AmbiguousNumber.kt.golden b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/AmbiguousNumber.kt.golden new file mode 100644 index 000000000..011176ff0 --- /dev/null +++ b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/AmbiguousNumber.kt.golden @@ -0,0 +1,116 @@ +// Code generated by openapi-nexus. DO NOT EDIT. +// +// Kotlin additional properties — 1.0.0 + +package com.example.sdk.models + +import com.google.gson.Gson +import com.google.gson.JsonElement +import com.google.gson.JsonNull +import com.google.gson.JsonObject +import com.google.gson.JsonParseException +import com.google.gson.JsonParser +import com.google.gson.JsonPrimitive +import com.google.gson.TypeAdapter +import com.google.gson.annotations.JsonAdapter +import com.google.gson.reflect.TypeToken +import com.google.gson.stream.JsonReader +import com.google.gson.stream.JsonWriter + +@JsonAdapter(AmbiguousNumberJsonAdapter::class, nullSafe = false) +sealed class AmbiguousNumber private constructor() { + data class VariantInteger(val value0: Int) : AmbiguousNumber() + + data class VariantNumber(val value0: Double) : AmbiguousNumber() +} + +private class AmbiguousNumberJsonAdapter : TypeAdapter() { + private val gson: Gson = Gson() + + override fun read(reader: JsonReader): AmbiguousNumber? { + val element = JsonParser.parseReader(reader) + if (element.isJsonNull) { + return null + } + var selected = -1 + if (selected < 0 && matches0(element)) { + selected = 0 + } + if (selected < 0 && matches1(element)) { + selected = 1 + } + if (selected == 0) { + return AmbiguousNumber.VariantInteger(element.asInt) + } + if (selected == 1) { + return AmbiguousNumber.VariantNumber(element.asDouble) + } + throw JsonParseException("unknown AmbiguousNumber union value") + } + + override fun write(writer: JsonWriter, value: AmbiguousNumber?) { + if (value == null) { + writer.nullValue() + return + } + when (value) { + is AmbiguousNumber.VariantInteger -> { + gson.toJson(value.value0, object: TypeToken() {}.type, writer) + } + is AmbiguousNumber.VariantNumber -> { + gson.toJson(value.value0, object: TypeToken() {}.type, writer) + } + } + } + + private fun matches0(candidate: JsonElement): kotlin.Boolean { + return candidate.isJsonPrimitive && candidate.asJsonPrimitive.isNumber && isIntegerNumber(candidate) + } + + private fun matches1(candidate: JsonElement): kotlin.Boolean { + return candidate.isJsonPrimitive && candidate.asJsonPrimitive.isNumber + } + + private fun numberSignificand(literal: kotlin.String): java.math.BigDecimal { + val exponent = maxOf(literal.indexOf('e'), literal.indexOf('E')) + return java.math.BigDecimal(if (exponent < 0) literal else literal.substring(0, exponent)).stripTrailingZeros() + } + + private fun numberExponent(literal: kotlin.String): java.math.BigInteger { + val exponent = maxOf(literal.indexOf('e'), literal.indexOf('E')) + return if (exponent < 0) java.math.BigInteger.ZERO else java.math.BigInteger(literal.substring(exponent + 1)) + } + + private fun isIntegerNumber(candidate: JsonElement): kotlin.Boolean { + val literal = candidate.asString + val significand = numberSignificand(literal) + return significand.signum() == 0 || numberExponent(literal).compareTo(java.math.BigInteger.valueOf(significand.scale().toLong())) >= 0 + } + + private fun isBinaryNumber(candidate: JsonElement): kotlin.Boolean { + if (!candidate.isJsonPrimitive || !candidate.asJsonPrimitive.isNumber || !isIntegerNumber(candidate)) { + return false + } + val literal = candidate.asString + val significand = numberSignificand(literal) + if (significand.signum() == 0) { + return true + } + val scale = numberExponent(literal).subtract(java.math.BigInteger.valueOf(significand.scale().toLong())) + if (scale.compareTo(java.math.BigInteger.valueOf(2)) > 0) { + return false + } + val number = significand.unscaledValue().multiply(java.math.BigInteger.TEN.pow(scale.toInt())) + return number.compareTo(java.math.BigInteger.valueOf(-128)) >= 0 && number.compareTo(java.math.BigInteger.valueOf(255)) <= 0 + } + + private fun matchesNumber(candidate: JsonElement, expected: kotlin.String): kotlin.Boolean { + val literal = candidate.asString + val significand = numberSignificand(literal) + val expectedNumber = java.math.BigDecimal(expected).stripTrailingZeros() + if (significand.unscaledValue() != expectedNumber.unscaledValue()) { + return false + } + return significand.signum() == 0 || numberExponent(literal) == java.math.BigInteger.valueOf(significand.scale().toLong() - expectedNumber.scale()) + } +} diff --git a/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/AmbiguousValues.kt.golden b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/AmbiguousValues.kt.golden new file mode 100644 index 000000000..46f7937d2 --- /dev/null +++ b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/AmbiguousValues.kt.golden @@ -0,0 +1,48 @@ +// Code generated by openapi-nexus. DO NOT EDIT. +// +// Kotlin additional properties — 1.0.0 + +package com.example.sdk.models + +import com.google.gson.annotations.SerializedName + +@com.google.gson.annotations.JsonAdapter(AmbiguousValuesAdditionalPropertiesAdapterFactory::class, nullSafe = false) +data class AmbiguousValues(@field:SerializedName("known_field") + val knownField: String, @field:kotlin.jvm.Transient + var additionalProperties: kotlin.collections.MutableMap = linkedMapOf()) { + constructor(knownField: kotlin.String) : this(knownField, linkedMapOf()) { + + } +} + +private class AmbiguousValuesAdditionalPropertiesAdapterFactory : com.example.sdk.runtime.AdditionalPropertiesAdapterFactory { + constructor() : super(com.example.sdk.models.AmbiguousValues::class.java, object: com.google.gson.reflect.TypeToken() {}, "known_field") { + + } + + override fun properties(value: com.example.sdk.models.AmbiguousValues): kotlin.collections.MutableMap { + return value.additionalProperties + } + + override fun setProperties(value: com.example.sdk.models.AmbiguousValues, + entries: kotlin.collections.MutableMap) { + value.additionalProperties = entries + } + + override fun encode(gson: com.google.gson.Gson, + value: com.example.sdk.models.AmbiguousNumber?): com.google.gson.JsonElement { + return additionalValue0(gson, value) + } + + private fun additionalValue0(gson: com.google.gson.Gson, + value: com.example.sdk.models.AmbiguousNumber?): com.google.gson.JsonElement { + if (value == null) { + return com.google.gson.JsonNull.INSTANCE + } + return gson.toJsonTree(value, object: com.google.gson.reflect.TypeToken() {}.type) + } +} diff --git a/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/Choices.kt.golden b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/Choices.kt.golden new file mode 100644 index 000000000..c1d8be241 --- /dev/null +++ b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/Choices.kt.golden @@ -0,0 +1,47 @@ +// Code generated by openapi-nexus. DO NOT EDIT. +// +// Kotlin additional properties — 1.0.0 + +package com.example.sdk.models + +import com.google.gson.annotations.SerializedName + +@com.google.gson.annotations.JsonAdapter(ChoicesAdditionalPropertiesAdapterFactory::class, nullSafe = false) +data class Choices(@field:SerializedName("known_field") + val knownField: String, @field:kotlin.jvm.Transient + var additionalProperties: kotlin.collections.MutableMap = linkedMapOf()) { + constructor(knownField: kotlin.String) : this(knownField, linkedMapOf()) { + + } +} + +private class ChoicesAdditionalPropertiesAdapterFactory : com.example.sdk.runtime.AdditionalPropertiesAdapterFactory { + constructor() : super(com.example.sdk.models.Choices::class.java, object: com.google.gson.reflect.TypeToken() {}, "known_field") { + + } + + override fun properties(value: com.example.sdk.models.Choices): kotlin.collections.MutableMap { + return value.additionalProperties + } + + override fun setProperties(value: com.example.sdk.models.Choices, + entries: kotlin.collections.MutableMap) { + value.additionalProperties = entries + } + + override fun encode(gson: com.google.gson.Gson, + value: com.example.sdk.models.ValueChoice?): com.google.gson.JsonElement { + return additionalValue0(gson, value) + } + + private fun additionalValue0(gson: com.google.gson.Gson, + value: com.example.sdk.models.ValueChoice?): com.google.gson.JsonElement { + if (value == null) { + return com.google.gson.JsonNull.INSTANCE + } + return gson.toJsonTree(value, object: com.google.gson.reflect.TypeToken() {}.type) + } +} diff --git a/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/Closed.kt.golden b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/Closed.kt.golden new file mode 100644 index 000000000..2607c09c3 --- /dev/null +++ b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/Closed.kt.golden @@ -0,0 +1,11 @@ +// Code generated by openapi-nexus. DO NOT EDIT. +// +// Kotlin additional properties — 1.0.0 + +package com.example.sdk.models + +import com.google.gson.annotations.SerializedName + +data class Closed(@field:SerializedName("known_field") + val knownField: String? = null) { +} diff --git a/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/DeepValues.kt.golden b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/DeepValues.kt.golden new file mode 100644 index 000000000..16693965c --- /dev/null +++ b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/DeepValues.kt.golden @@ -0,0 +1,80 @@ +// Code generated by openapi-nexus. DO NOT EDIT. +// +// Kotlin additional properties — 1.0.0 + +package com.example.sdk.models + +import com.google.gson.annotations.SerializedName + +@com.google.gson.annotations.JsonAdapter(DeepValuesAdditionalPropertiesAdapterFactory::class, nullSafe = false) +data class DeepValues(@field:SerializedName("known_field") + val knownField: String, @field:kotlin.jvm.Transient + var additionalProperties: kotlin.collections.MutableMap>> = linkedMapOf()) { + constructor(knownField: kotlin.String) : this(knownField, linkedMapOf()) { + + } +} + +private class DeepValuesAdditionalPropertiesAdapterFactory : com.example.sdk.runtime.AdditionalPropertiesAdapterFactory>> { + constructor() : super(com.example.sdk.models.DeepValues::class.java, object: com.google.gson.reflect.TypeToken>>() {}, "known_field") { + + } + + override fun properties(value: com.example.sdk.models.DeepValues): kotlin.collections.MutableMap>> { + return value.additionalProperties + } + + override fun setProperties(value: com.example.sdk.models.DeepValues, + entries: kotlin.collections.MutableMap>>) { + value.additionalProperties = entries + } + + override fun encode(gson: com.google.gson.Gson, + value: kotlin.collections.List>?): com.google.gson.JsonElement { + return additionalValue2(gson, value) + } + + private fun additionalValue0(gson: com.google.gson.Gson, + value: com.example.sdk.models.NestedValue?): com.google.gson.JsonElement { + if (value == null) { + return com.google.gson.JsonNull.INSTANCE + } + return gson.toJsonTree(value, object: com.google.gson.reflect.TypeToken() {}.type) + } + + private fun additionalValue1(gson: com.google.gson.Gson, + value: kotlin.collections.Map?): com.google.gson.JsonElement { + if (value == null) { + return com.google.gson.JsonNull.INSTANCE + } + val result = com.google.gson.JsonObject() + for (entry in value.entries) { + result.add(entry.key, additionalValue0(gson, entry.value)) + } + return result + } + + private fun additionalValue2(gson: com.google.gson.Gson, + value: kotlin.collections.List>?): com.google.gson.JsonElement { + if (value == null) { + return com.google.gson.JsonNull.INSTANCE + } + val result = com.google.gson.JsonArray() + for (item in value) { + result.add(additionalValue1(gson, item)) + } + return result + } +} diff --git a/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/Holder.kt.golden b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/Holder.kt.golden new file mode 100644 index 000000000..4d19773d5 --- /dev/null +++ b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/Holder.kt.golden @@ -0,0 +1,8 @@ +// Code generated by openapi-nexus. DO NOT EDIT. +// +// Kotlin additional properties — 1.0.0 + +package com.example.sdk.models + +data class Holder(val dictionaries: List, val mixed: MixedMap, val opaque: OpaqueMap) { +} diff --git a/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/Implicit.kt.golden b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/Implicit.kt.golden new file mode 100644 index 000000000..657921b42 --- /dev/null +++ b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/Implicit.kt.golden @@ -0,0 +1,11 @@ +// Code generated by openapi-nexus. DO NOT EDIT. +// +// Kotlin additional properties — 1.0.0 + +package com.example.sdk.models + +import com.google.gson.annotations.SerializedName + +data class Implicit(@field:SerializedName("known_field") + val knownField: String? = null) { +} diff --git a/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/IntegerValues.kt.golden b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/IntegerValues.kt.golden new file mode 100644 index 000000000..7b40d9c6e --- /dev/null +++ b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/IntegerValues.kt.golden @@ -0,0 +1,47 @@ +// Code generated by openapi-nexus. DO NOT EDIT. +// +// Kotlin additional properties — 1.0.0 + +package com.example.sdk.models + +import com.google.gson.annotations.SerializedName + +@com.google.gson.annotations.JsonAdapter(IntegerValuesAdditionalPropertiesAdapterFactory::class, nullSafe = false) +data class IntegerValues(@field:SerializedName("known_field") + val knownField: String, @field:kotlin.jvm.Transient + var additionalProperties: kotlin.collections.MutableMap = linkedMapOf()) { + constructor(knownField: kotlin.String) : this(knownField, linkedMapOf()) { + + } +} + +private class IntegerValuesAdditionalPropertiesAdapterFactory : com.example.sdk.runtime.AdditionalPropertiesAdapterFactory { + constructor() : super(com.example.sdk.models.IntegerValues::class.java, object: com.google.gson.reflect.TypeToken() {}, "known_field") { + + } + + override fun properties(value: com.example.sdk.models.IntegerValues): kotlin.collections.MutableMap { + return value.additionalProperties + } + + override fun setProperties(value: com.example.sdk.models.IntegerValues, + entries: kotlin.collections.MutableMap) { + value.additionalProperties = entries + } + + override fun encode(gson: com.google.gson.Gson, + value: kotlin.Int?): com.google.gson.JsonElement { + return additionalValue0(gson, value) + } + + private fun additionalValue0(gson: com.google.gson.Gson, + value: kotlin.Int?): com.google.gson.JsonElement { + if (value == null) { + return com.google.gson.JsonNull.INSTANCE + } + return gson.toJsonTree(value, object: com.google.gson.reflect.TypeToken() {}.type) + } +} diff --git a/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/LongValues.kt.golden b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/LongValues.kt.golden new file mode 100644 index 000000000..98738b4d2 --- /dev/null +++ b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/LongValues.kt.golden @@ -0,0 +1,47 @@ +// Code generated by openapi-nexus. DO NOT EDIT. +// +// Kotlin additional properties — 1.0.0 + +package com.example.sdk.models + +import com.google.gson.annotations.SerializedName + +@com.google.gson.annotations.JsonAdapter(LongValuesAdditionalPropertiesAdapterFactory::class, nullSafe = false) +data class LongValues(@field:SerializedName("known_field") + val knownField: String, @field:kotlin.jvm.Transient + var additionalProperties: kotlin.collections.MutableMap = linkedMapOf()) { + constructor(knownField: kotlin.String) : this(knownField, linkedMapOf()) { + + } +} + +private class LongValuesAdditionalPropertiesAdapterFactory : com.example.sdk.runtime.AdditionalPropertiesAdapterFactory { + constructor() : super(com.example.sdk.models.LongValues::class.java, object: com.google.gson.reflect.TypeToken() {}, "known_field") { + + } + + override fun properties(value: com.example.sdk.models.LongValues): kotlin.collections.MutableMap { + return value.additionalProperties + } + + override fun setProperties(value: com.example.sdk.models.LongValues, + entries: kotlin.collections.MutableMap) { + value.additionalProperties = entries + } + + override fun encode(gson: com.google.gson.Gson, + value: kotlin.Long?): com.google.gson.JsonElement { + return additionalValue0(gson, value) + } + + private fun additionalValue0(gson: com.google.gson.Gson, + value: kotlin.Long?): com.google.gson.JsonElement { + if (value == null) { + return com.google.gson.JsonNull.INSTANCE + } + return gson.toJsonTree(value, object: com.google.gson.reflect.TypeToken() {}.type) + } +} diff --git a/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/MixedChoice.kt.golden b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/MixedChoice.kt.golden new file mode 100644 index 000000000..4d0910bf9 --- /dev/null +++ b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/MixedChoice.kt.golden @@ -0,0 +1,104 @@ +// Code generated by openapi-nexus. DO NOT EDIT. +// +// Kotlin additional properties — 1.0.0 + +package com.example.sdk.models + +import com.google.gson.Gson +import com.google.gson.JsonElement +import com.google.gson.JsonNull +import com.google.gson.JsonObject +import com.google.gson.JsonParseException +import com.google.gson.JsonParser +import com.google.gson.JsonPrimitive +import com.google.gson.TypeAdapter +import com.google.gson.annotations.JsonAdapter +import com.google.gson.reflect.TypeToken +import com.google.gson.stream.JsonReader +import com.google.gson.stream.JsonWriter + +@JsonAdapter(MixedChoiceJsonAdapter::class, nullSafe = false) +sealed class MixedChoice private constructor() { + data class VariantMixedMap(val value0: MixedMap) : MixedChoice() + + data class VariantString(val value0: String) : MixedChoice() +} + +private class MixedChoiceJsonAdapter : TypeAdapter() { + private val gson: Gson = Gson() + + override fun read(reader: JsonReader): MixedChoice? { + val element = JsonParser.parseReader(reader) + if (element.isJsonNull) { + return null + } + var selected = -1 + if (selected < 0 && matches0(element)) { + selected = 0 + } + if (selected < 0 && matches2(element)) { + selected = 1 + } + if (selected == 0) { + return MixedChoice.VariantMixedMap(gson.fromJson(element, object: TypeToken() {}.type)) + } + if (selected == 1) { + return MixedChoice.VariantString(element.asString) + } + throw JsonParseException("unknown MixedChoice union value") + } + + override fun write(writer: JsonWriter, value: MixedChoice?) { + if (value == null) { + writer.nullValue() + return + } + when (value) { + is MixedChoice.VariantMixedMap -> { + gson.toJson(value.value0, object: TypeToken() {}.type, writer) + } + is MixedChoice.VariantString -> { + gson.toJson(value.value0, object: TypeToken() {}.type, writer) + } + } + } + + private fun matches0(candidate: JsonElement): kotlin.Boolean { + if (!matches1(candidate)) { + return false + } + val declared = setOf("known_field") + for (item in candidate.asJsonObject.entrySet()) { + if (!declared.contains(item.key) && !matches3(item.value)) { + return false + } + } + return true + } + + private fun matches1(candidate: JsonElement): kotlin.Boolean { + if (!candidate.isJsonObject) { + return false + } + val jsonObject = candidate.asJsonObject + if (!jsonObject.has("known_field") || !matches2(jsonObject.get("known_field"))) { + return false + } + return true + } + + private fun matches2(candidate: JsonElement): kotlin.Boolean { + return candidate.isJsonPrimitive && candidate.asJsonPrimitive.isString + } + + private fun matches3(candidate: JsonElement): kotlin.Boolean { + if (!candidate.isJsonObject) { + return false + } + val jsonObject = candidate.asJsonObject + if (!jsonObject.has("wire_value") || !matches2(jsonObject.get("wire_value"))) { + return false + } + return true + } +} diff --git a/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/MixedMap.kt.golden b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/MixedMap.kt.golden new file mode 100644 index 000000000..15b7f981d --- /dev/null +++ b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/MixedMap.kt.golden @@ -0,0 +1,47 @@ +// Code generated by openapi-nexus. DO NOT EDIT. +// +// Kotlin additional properties — 1.0.0 + +package com.example.sdk.models + +import com.google.gson.annotations.SerializedName + +@com.google.gson.annotations.JsonAdapter(MixedMapAdditionalPropertiesAdapterFactory::class, nullSafe = false) +data class MixedMap(@field:SerializedName("known_field") + val knownField: String, @field:kotlin.jvm.Transient + var additionalProperties: kotlin.collections.MutableMap = linkedMapOf()) { + constructor(knownField: kotlin.String) : this(knownField, linkedMapOf()) { + + } +} + +private class MixedMapAdditionalPropertiesAdapterFactory : com.example.sdk.runtime.AdditionalPropertiesAdapterFactory { + constructor() : super(com.example.sdk.models.MixedMap::class.java, object: com.google.gson.reflect.TypeToken() {}, "known_field") { + + } + + override fun properties(value: com.example.sdk.models.MixedMap): kotlin.collections.MutableMap { + return value.additionalProperties + } + + override fun setProperties(value: com.example.sdk.models.MixedMap, + entries: kotlin.collections.MutableMap) { + value.additionalProperties = entries + } + + override fun encode(gson: com.google.gson.Gson, + value: com.example.sdk.models.NestedValue?): com.google.gson.JsonElement { + return additionalValue0(gson, value) + } + + private fun additionalValue0(gson: com.google.gson.Gson, + value: com.example.sdk.models.NestedValue?): com.google.gson.JsonElement { + if (value == null) { + return com.google.gson.JsonNull.INSTANCE + } + return gson.toJsonTree(value, object: com.google.gson.reflect.TypeToken() {}.type) + } +} diff --git a/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/NamedOpaque.kt.golden b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/NamedOpaque.kt.golden new file mode 100644 index 000000000..5ed90b15f --- /dev/null +++ b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/NamedOpaque.kt.golden @@ -0,0 +1,49 @@ +// Code generated by openapi-nexus. DO NOT EDIT. +// +// Kotlin additional properties — 1.0.0 + +package com.example.sdk.models + +import com.google.gson.annotations.SerializedName + +@com.google.gson.annotations.JsonAdapter(NamedOpaqueAdditionalPropertiesAdapterFactory::class, nullSafe = false) +data class NamedOpaque(@field:SerializedName("known_field") + val knownField: Any, @field:SerializedName("known_values") + val knownValues: List? = null, @field:kotlin.jvm.Transient + var additionalProperties: kotlin.collections.MutableMap = linkedMapOf()) { + constructor(knownField: kotlin.Any, + knownValues: kotlin.collections.List?) : this(knownField, knownValues, linkedMapOf()) { + + } +} + +private class NamedOpaqueAdditionalPropertiesAdapterFactory : com.example.sdk.runtime.AdditionalPropertiesAdapterFactory { + constructor() : super(com.example.sdk.models.NamedOpaque::class.java, object: com.google.gson.reflect.TypeToken() {}, "known_field", "known_values") { + + } + + override fun properties(value: com.example.sdk.models.NamedOpaque): kotlin.collections.MutableMap { + return value.additionalProperties + } + + override fun setProperties(value: com.example.sdk.models.NamedOpaque, + entries: kotlin.collections.MutableMap) { + value.additionalProperties = entries + } + + override fun encode(gson: com.google.gson.Gson, + value: com.google.gson.JsonElement?): com.google.gson.JsonElement { + return additionalValue0(gson, value) + } + + private fun additionalValue0(gson: com.google.gson.Gson, + value: com.google.gson.JsonElement?): com.google.gson.JsonElement { + if (value == null) { + return com.google.gson.JsonNull.INSTANCE + } + return value + } +} diff --git a/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/NestedValue.kt.golden b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/NestedValue.kt.golden new file mode 100644 index 000000000..08c2bee7d --- /dev/null +++ b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/NestedValue.kt.golden @@ -0,0 +1,11 @@ +// Code generated by openapi-nexus. DO NOT EDIT. +// +// Kotlin additional properties — 1.0.0 + +package com.example.sdk.models + +import com.google.gson.annotations.SerializedName + +data class NestedValue(@field:SerializedName("wire_value") + val wireValue: String) { +} diff --git a/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/NullableValues.kt.golden b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/NullableValues.kt.golden new file mode 100644 index 000000000..c6c3e0274 --- /dev/null +++ b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/NullableValues.kt.golden @@ -0,0 +1,48 @@ +// Code generated by openapi-nexus. DO NOT EDIT. +// +// Kotlin additional properties — 1.0.0 + +package com.example.sdk.models + +import com.google.gson.annotations.SerializedName + +@com.google.gson.annotations.JsonAdapter(NullableValuesAdditionalPropertiesAdapterFactory::class, nullSafe = false) +data class NullableValues(@field:SerializedName("known_field") + val knownField: String, @field:kotlin.jvm.Transient + var additionalProperties: kotlin.collections.MutableMap = linkedMapOf()) { + constructor(knownField: kotlin.String) : this(knownField, linkedMapOf()) { + + } +} + +private class NullableValuesAdditionalPropertiesAdapterFactory : com.example.sdk.runtime.AdditionalPropertiesAdapterFactory { + constructor() : super(com.example.sdk.models.NullableValues::class.java, object: com.google.gson.reflect.TypeToken() {}, "known_field") { + + } + + override fun properties(value: com.example.sdk.models.NullableValues): kotlin.collections.MutableMap { + return value.additionalProperties + } + + override fun setProperties(value: com.example.sdk.models.NullableValues, + entries: kotlin.collections.MutableMap) { + value.additionalProperties = entries + } + + override fun encode(gson: com.google.gson.Gson, + value: com.example.sdk.models.NestedValue?): com.google.gson.JsonElement { + return additionalValue0(gson, value) + } + + private fun additionalValue0(gson: com.google.gson.Gson, + value: com.example.sdk.models.NestedValue?): com.google.gson.JsonElement { + if (value == null) { + return com.google.gson.JsonNull.INSTANCE + } + return gson.toJsonTree(value, object: com.google.gson.reflect.TypeToken() {}.type) + } +} diff --git a/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/OpaqueMap.kt.golden b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/OpaqueMap.kt.golden new file mode 100644 index 000000000..cd222d979 --- /dev/null +++ b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/OpaqueMap.kt.golden @@ -0,0 +1,47 @@ +// Code generated by openapi-nexus. DO NOT EDIT. +// +// Kotlin additional properties — 1.0.0 + +package com.example.sdk.models + +import com.google.gson.annotations.SerializedName + +@com.google.gson.annotations.JsonAdapter(OpaqueMapAdditionalPropertiesAdapterFactory::class, nullSafe = false) +data class OpaqueMap(@field:SerializedName("known_field") + val knownField: String, @field:kotlin.jvm.Transient + var additionalProperties: kotlin.collections.MutableMap = linkedMapOf()) { + constructor(knownField: kotlin.String) : this(knownField, linkedMapOf()) { + + } +} + +private class OpaqueMapAdditionalPropertiesAdapterFactory : com.example.sdk.runtime.AdditionalPropertiesAdapterFactory { + constructor() : super(com.example.sdk.models.OpaqueMap::class.java, object: com.google.gson.reflect.TypeToken() {}, "known_field") { + + } + + override fun properties(value: com.example.sdk.models.OpaqueMap): kotlin.collections.MutableMap { + return value.additionalProperties + } + + override fun setProperties(value: com.example.sdk.models.OpaqueMap, + entries: kotlin.collections.MutableMap) { + value.additionalProperties = entries + } + + override fun encode(gson: com.google.gson.Gson, + value: com.google.gson.JsonElement?): com.google.gson.JsonElement { + return additionalValue0(gson, value) + } + + private fun additionalValue0(gson: com.google.gson.Gson, + value: com.google.gson.JsonElement?): com.google.gson.JsonElement { + if (value == null) { + return com.google.gson.JsonNull.INSTANCE + } + return value + } +} diff --git a/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/OpenNumbers.kt.golden b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/OpenNumbers.kt.golden new file mode 100644 index 000000000..a18d96508 --- /dev/null +++ b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/OpenNumbers.kt.golden @@ -0,0 +1,50 @@ +// Code generated by openapi-nexus. DO NOT EDIT. +// +// Kotlin additional properties — 1.0.0 + +package com.example.sdk.models + +import com.google.gson.annotations.SerializedName + +@com.google.gson.annotations.JsonAdapter(OpenNumbersAdditionalPropertiesAdapterFactory::class, nullSafe = false) +data class OpenNumbers(val count: Int, @field:SerializedName("nullable_value") + val nullableValue: NestedValue? = null, @field:SerializedName("optional_count") + val optionalCount: Int? = null, val weight: Double? = null, @field:kotlin.jvm.Transient + var additionalProperties: kotlin.collections.MutableMap = linkedMapOf()) { + constructor(count: kotlin.Int, nullableValue: com.example.sdk.models.NestedValue?, + optionalCount: kotlin.Int?, + weight: kotlin.Double?) : this(count, nullableValue, optionalCount, weight, linkedMapOf()) { + + } +} + +private class OpenNumbersAdditionalPropertiesAdapterFactory : com.example.sdk.runtime.AdditionalPropertiesAdapterFactory { + constructor() : super(com.example.sdk.models.OpenNumbers::class.java, object: com.google.gson.reflect.TypeToken() {}, "count", "nullable_value", "optional_count", "weight") { + + } + + override fun properties(value: com.example.sdk.models.OpenNumbers): kotlin.collections.MutableMap { + return value.additionalProperties + } + + override fun setProperties(value: com.example.sdk.models.OpenNumbers, + entries: kotlin.collections.MutableMap) { + value.additionalProperties = entries + } + + override fun encode(gson: com.google.gson.Gson, + value: com.google.gson.JsonElement?): com.google.gson.JsonElement { + return additionalValue0(gson, value) + } + + private fun additionalValue0(gson: com.google.gson.Gson, + value: com.google.gson.JsonElement?): com.google.gson.JsonElement { + if (value == null) { + return com.google.gson.JsonNull.INSTANCE + } + return value + } +} diff --git a/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/OptionalKnown.kt.golden b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/OptionalKnown.kt.golden new file mode 100644 index 000000000..297b870eb --- /dev/null +++ b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/OptionalKnown.kt.golden @@ -0,0 +1,47 @@ +// Code generated by openapi-nexus. DO NOT EDIT. +// +// Kotlin additional properties — 1.0.0 + +package com.example.sdk.models + +import com.google.gson.annotations.SerializedName + +@com.google.gson.annotations.JsonAdapter(OptionalKnownAdditionalPropertiesAdapterFactory::class, nullSafe = false) +data class OptionalKnown(@field:SerializedName("known_field") + val knownField: String? = null, @field:kotlin.jvm.Transient + var additionalProperties: kotlin.collections.MutableMap = linkedMapOf()) { + constructor(knownField: kotlin.String?) : this(knownField, linkedMapOf()) { + + } +} + +private class OptionalKnownAdditionalPropertiesAdapterFactory : com.example.sdk.runtime.AdditionalPropertiesAdapterFactory { + constructor() : super(com.example.sdk.models.OptionalKnown::class.java, object: com.google.gson.reflect.TypeToken() {}, "known_field") { + + } + + override fun properties(value: com.example.sdk.models.OptionalKnown): kotlin.collections.MutableMap { + return value.additionalProperties + } + + override fun setProperties(value: com.example.sdk.models.OptionalKnown, + entries: kotlin.collections.MutableMap) { + value.additionalProperties = entries + } + + override fun encode(gson: com.google.gson.Gson, + value: com.google.gson.JsonElement?): com.google.gson.JsonElement { + return additionalValue0(gson, value) + } + + private fun additionalValue0(gson: com.google.gson.Gson, + value: com.google.gson.JsonElement?): com.google.gson.JsonElement { + if (value == null) { + return com.google.gson.JsonNull.INSTANCE + } + return value + } +} diff --git a/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/PlainNumbers.kt.golden b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/PlainNumbers.kt.golden new file mode 100644 index 000000000..61285c9bb --- /dev/null +++ b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/PlainNumbers.kt.golden @@ -0,0 +1,12 @@ +// Code generated by openapi-nexus. DO NOT EDIT. +// +// Kotlin additional properties — 1.0.0 + +package com.example.sdk.models + +import com.google.gson.annotations.SerializedName + +data class PlainNumbers(val count: Int, @field:SerializedName("nullable_value") + val nullableValue: NestedValue? = null, @field:SerializedName("optional_count") + val optionalCount: Int? = null, val weight: Double? = null) { +} diff --git a/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/RawContainers.kt.golden b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/RawContainers.kt.golden new file mode 100644 index 000000000..797670387 --- /dev/null +++ b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/RawContainers.kt.golden @@ -0,0 +1,78 @@ +// Code generated by openapi-nexus. DO NOT EDIT. +// +// Kotlin additional properties — 1.0.0 + +package com.example.sdk.models + +import com.google.gson.annotations.SerializedName + +@com.google.gson.annotations.JsonAdapter(RawContainersAdditionalPropertiesAdapterFactory::class, nullSafe = false) +data class RawContainers(@field:SerializedName("known_field") + val knownField: String, @field:kotlin.jvm.Transient + var additionalProperties: kotlin.collections.MutableMap>> = linkedMapOf()) { + constructor(knownField: kotlin.String) : this(knownField, linkedMapOf()) { + + } +} + +private class RawContainersAdditionalPropertiesAdapterFactory : com.example.sdk.runtime.AdditionalPropertiesAdapterFactory>> { + constructor() : super(com.example.sdk.models.RawContainers::class.java, object: com.google.gson.reflect.TypeToken>>() {}, "known_field") { + + } + + override fun properties(value: com.example.sdk.models.RawContainers): kotlin.collections.MutableMap>> { + return value.additionalProperties + } + + override fun setProperties(value: com.example.sdk.models.RawContainers, + entries: kotlin.collections.MutableMap>>) { + value.additionalProperties = entries + } + + override fun encode(gson: com.google.gson.Gson, + value: kotlin.collections.List>?): com.google.gson.JsonElement { + return additionalValue2(gson, value) + } + + private fun additionalValue0(gson: com.google.gson.Gson, + value: com.google.gson.JsonElement?): com.google.gson.JsonElement { + if (value == null) { + return com.google.gson.JsonNull.INSTANCE + } + return value + } + + private fun additionalValue1(gson: com.google.gson.Gson, + value: kotlin.collections.Map?): com.google.gson.JsonElement { + if (value == null) { + return com.google.gson.JsonNull.INSTANCE + } + val result = com.google.gson.JsonObject() + for (entry in value.entries) { + result.add(entry.key, additionalValue0(gson, entry.value)) + } + return result + } + + private fun additionalValue2(gson: com.google.gson.Gson, + value: kotlin.collections.List>?): com.google.gson.JsonElement { + if (value == null) { + return com.google.gson.JsonNull.INSTANCE + } + val result = com.google.gson.JsonArray() + for (item in value) { + result.add(additionalValue1(gson, item)) + } + return result + } +} diff --git a/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/Recursive.kt.golden b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/Recursive.kt.golden new file mode 100644 index 000000000..0120dbb8a --- /dev/null +++ b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/Recursive.kt.golden @@ -0,0 +1,47 @@ +// Code generated by openapi-nexus. DO NOT EDIT. +// +// Kotlin additional properties — 1.0.0 + +package com.example.sdk.models + +import com.google.gson.annotations.SerializedName + +@com.google.gson.annotations.JsonAdapter(RecursiveAdditionalPropertiesAdapterFactory::class, nullSafe = false) +data class Recursive(@field:SerializedName("wire_value") + val wireValue: String, @field:kotlin.jvm.Transient + var additionalProperties: kotlin.collections.MutableMap = linkedMapOf()) { + constructor(wireValue: kotlin.String) : this(wireValue, linkedMapOf()) { + + } +} + +private class RecursiveAdditionalPropertiesAdapterFactory : com.example.sdk.runtime.AdditionalPropertiesAdapterFactory { + constructor() : super(com.example.sdk.models.Recursive::class.java, object: com.google.gson.reflect.TypeToken() {}, "wire_value") { + + } + + override fun properties(value: com.example.sdk.models.Recursive): kotlin.collections.MutableMap { + return value.additionalProperties + } + + override fun setProperties(value: com.example.sdk.models.Recursive, + entries: kotlin.collections.MutableMap) { + value.additionalProperties = entries + } + + override fun encode(gson: com.google.gson.Gson, + value: com.example.sdk.models.Recursive?): com.google.gson.JsonElement { + return additionalValue0(gson, value) + } + + private fun additionalValue0(gson: com.google.gson.Gson, + value: com.example.sdk.models.Recursive?): com.google.gson.JsonElement { + if (value == null) { + return com.google.gson.JsonNull.INSTANCE + } + return gson.toJsonTree(value, object: com.google.gson.reflect.TypeToken() {}.type) + } +} diff --git a/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/Status.kt.golden b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/Status.kt.golden new file mode 100644 index 000000000..d24d44d66 --- /dev/null +++ b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/Status.kt.golden @@ -0,0 +1,14 @@ +// Code generated by openapi-nexus. DO NOT EDIT. +// +// Kotlin additional properties — 1.0.0 + +package com.example.sdk.models + +import com.google.gson.annotations.SerializedName + +enum class Status(val value: String) { + @SerializedName("active") + Active("active"), + @SerializedName("inactive") + Inactive("inactive") +} diff --git a/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/StatusValues.kt.golden b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/StatusValues.kt.golden new file mode 100644 index 000000000..97cfdba2f --- /dev/null +++ b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/StatusValues.kt.golden @@ -0,0 +1,47 @@ +// Code generated by openapi-nexus. DO NOT EDIT. +// +// Kotlin additional properties — 1.0.0 + +package com.example.sdk.models + +import com.google.gson.annotations.SerializedName + +@com.google.gson.annotations.JsonAdapter(StatusValuesAdditionalPropertiesAdapterFactory::class, nullSafe = false) +data class StatusValues(@field:SerializedName("known_field") + val knownField: String, @field:kotlin.jvm.Transient + var additionalProperties: kotlin.collections.MutableMap = linkedMapOf()) { + constructor(knownField: kotlin.String) : this(knownField, linkedMapOf()) { + + } +} + +private class StatusValuesAdditionalPropertiesAdapterFactory : com.example.sdk.runtime.AdditionalPropertiesAdapterFactory { + constructor() : super(com.example.sdk.models.StatusValues::class.java, object: com.google.gson.reflect.TypeToken() {}, "known_field") { + + } + + override fun properties(value: com.example.sdk.models.StatusValues): kotlin.collections.MutableMap { + return value.additionalProperties + } + + override fun setProperties(value: com.example.sdk.models.StatusValues, + entries: kotlin.collections.MutableMap) { + value.additionalProperties = entries + } + + override fun encode(gson: com.google.gson.Gson, + value: com.example.sdk.models.Status?): com.google.gson.JsonElement { + return additionalValue0(gson, value) + } + + private fun additionalValue0(gson: com.google.gson.Gson, + value: com.example.sdk.models.Status?): com.google.gson.JsonElement { + if (value == null) { + return com.google.gson.JsonNull.INSTANCE + } + return gson.toJsonTree(value, object: com.google.gson.reflect.TypeToken() {}.type) + } +} diff --git a/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/StorageCollision.kt.golden b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/StorageCollision.kt.golden new file mode 100644 index 000000000..fb7d8f1e5 --- /dev/null +++ b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/StorageCollision.kt.golden @@ -0,0 +1,44 @@ +// Code generated by openapi-nexus. DO NOT EDIT. +// +// Kotlin additional properties — 1.0.0 + +package com.example.sdk.models + +@com.google.gson.annotations.JsonAdapter(StorageCollisionAdditionalPropertiesAdapterFactory::class, nullSafe = false) +data class StorageCollision(val additionalProperties: String, @field:kotlin.jvm.Transient + var additionalProperties2: kotlin.collections.MutableMap = linkedMapOf()) { + constructor(additionalProperties: kotlin.String) : this(additionalProperties, linkedMapOf()) { + + } +} + +private class StorageCollisionAdditionalPropertiesAdapterFactory : com.example.sdk.runtime.AdditionalPropertiesAdapterFactory { + constructor() : super(com.example.sdk.models.StorageCollision::class.java, object: com.google.gson.reflect.TypeToken() {}, "additionalProperties") { + + } + + override fun properties(value: com.example.sdk.models.StorageCollision): kotlin.collections.MutableMap { + return value.additionalProperties2 + } + + override fun setProperties(value: com.example.sdk.models.StorageCollision, + entries: kotlin.collections.MutableMap) { + value.additionalProperties2 = entries + } + + override fun encode(gson: com.google.gson.Gson, + value: com.example.sdk.models.NestedValue?): com.google.gson.JsonElement { + return additionalValue0(gson, value) + } + + private fun additionalValue0(gson: com.google.gson.Gson, + value: com.example.sdk.models.NestedValue?): com.google.gson.JsonElement { + if (value == null) { + return com.google.gson.JsonNull.INSTANCE + } + return gson.toJsonTree(value, object: com.google.gson.reflect.TypeToken() {}.type) + } +} diff --git a/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/TypedMap.kt.golden b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/TypedMap.kt.golden new file mode 100644 index 000000000..26b04db51 --- /dev/null +++ b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/TypedMap.kt.golden @@ -0,0 +1,71 @@ +// Code generated by openapi-nexus. DO NOT EDIT. +// +// Kotlin additional properties — 1.0.0 + +package com.example.sdk.models + +import com.google.gson.Gson +import com.google.gson.JsonElement +import com.google.gson.JsonNull +import com.google.gson.JsonParseException +import com.google.gson.TypeAdapter +import com.google.gson.annotations.JsonAdapter +import com.google.gson.reflect.TypeToken +import com.google.gson.stream.JsonReader +import com.google.gson.stream.JsonToken +import com.google.gson.stream.JsonWriter + +@JsonAdapter(TypedMapJsonAdapter::class, nullSafe = false) +data class TypedMap(val additionalProperties: kotlin.collections.MutableMap = linkedMapOf()) { +} + +private class TypedMapJsonAdapter : TypeAdapter() { + private val gson: Gson = Gson() + + override fun read(reader: JsonReader): com.example.sdk.models.TypedMap? { + if (reader.peek() == JsonToken.NULL) { + reader.nextNull() + return null + } + if (reader.peek() != JsonToken.BEGIN_OBJECT) { + throw JsonParseException("expected TypedMap dictionary object") + } + reader.beginObject() + val result = com.example.sdk.models.TypedMap() + while (reader.hasNext()) { + val key = reader.nextName() + result.additionalProperties[key] = gson.fromJson(reader, object: TypeToken() {}.type) + } + reader.endObject() + return result + } + + override fun write(writer: JsonWriter, value: com.example.sdk.models.TypedMap?) { + if (value == null) { + writer.nullValue() + return + } + writer.beginObject() + for (entry in value.additionalProperties.entries) { + val tree = dictionaryValue0(entry.value) + writer.name(entry.key) + val serializeNulls = writer.serializeNulls + writer.serializeNulls = true + try { + gson.getAdapter(JsonElement::class.java).write(writer, tree) + } + finally { + writer.serializeNulls = serializeNulls + } + } + writer.endObject() + } + + private fun dictionaryValue0(value: com.example.sdk.models.NestedValue?): JsonElement { + if (value == null) { + return JsonNull.INSTANCE + } + return gson.toJsonTree(value, object: TypeToken() {}.type) + } +} diff --git a/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/ValueAlias.kt.golden b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/ValueAlias.kt.golden new file mode 100644 index 000000000..ab8240b14 --- /dev/null +++ b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/ValueAlias.kt.golden @@ -0,0 +1,7 @@ +// Code generated by openapi-nexus. DO NOT EDIT. +// +// Kotlin additional properties — 1.0.0 + +package com.example.sdk.models + +typealias ValueAlias = NestedValue diff --git a/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/ValueChoice.kt.golden b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/ValueChoice.kt.golden new file mode 100644 index 000000000..920847b6d --- /dev/null +++ b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/models/ValueChoice.kt.golden @@ -0,0 +1,127 @@ +// Code generated by openapi-nexus. DO NOT EDIT. +// +// Kotlin additional properties — 1.0.0 + +package com.example.sdk.models + +import com.google.gson.Gson +import com.google.gson.JsonElement +import com.google.gson.JsonNull +import com.google.gson.JsonObject +import com.google.gson.JsonParseException +import com.google.gson.JsonParser +import com.google.gson.JsonPrimitive +import com.google.gson.TypeAdapter +import com.google.gson.annotations.JsonAdapter +import com.google.gson.reflect.TypeToken +import com.google.gson.stream.JsonReader +import com.google.gson.stream.JsonWriter + +@JsonAdapter(ValueChoiceJsonAdapter::class, nullSafe = false) +sealed class ValueChoice private constructor() { + data class VariantNestedValue(val value0: NestedValue) : ValueChoice() + + data class VariantInteger(val value0: Int) : ValueChoice() +} + +private class ValueChoiceJsonAdapter : TypeAdapter() { + private val gson: Gson = Gson() + + override fun read(reader: JsonReader): ValueChoice? { + val element = JsonParser.parseReader(reader) + if (element.isJsonNull) { + return null + } + var selected = -1 + if (selected < 0 && matches0(element)) { + selected = 0 + } + if (selected < 0 && matches2(element)) { + selected = 1 + } + if (selected == 0) { + return ValueChoice.VariantNestedValue(gson.fromJson(element, object: TypeToken() {}.type)) + } + if (selected == 1) { + return ValueChoice.VariantInteger(element.asInt) + } + throw JsonParseException("unknown ValueChoice union value") + } + + override fun write(writer: JsonWriter, value: ValueChoice?) { + if (value == null) { + writer.nullValue() + return + } + when (value) { + is ValueChoice.VariantNestedValue -> { + gson.toJson(value.value0, object: TypeToken() {}.type, writer) + } + is ValueChoice.VariantInteger -> { + gson.toJson(value.value0, object: TypeToken() {}.type, writer) + } + } + } + + private fun matches0(candidate: JsonElement): kotlin.Boolean { + if (!candidate.isJsonObject) { + return false + } + val jsonObject = candidate.asJsonObject + if (!jsonObject.has("wire_value") || !matches1(jsonObject.get("wire_value"))) { + return false + } + return true + } + + private fun matches1(candidate: JsonElement): kotlin.Boolean { + return candidate.isJsonPrimitive && candidate.asJsonPrimitive.isString + } + + private fun matches2(candidate: JsonElement): kotlin.Boolean { + return candidate.isJsonPrimitive && candidate.asJsonPrimitive.isNumber && isIntegerNumber(candidate) + } + + private fun numberSignificand(literal: kotlin.String): java.math.BigDecimal { + val exponent = maxOf(literal.indexOf('e'), literal.indexOf('E')) + return java.math.BigDecimal(if (exponent < 0) literal else literal.substring(0, exponent)).stripTrailingZeros() + } + + private fun numberExponent(literal: kotlin.String): java.math.BigInteger { + val exponent = maxOf(literal.indexOf('e'), literal.indexOf('E')) + return if (exponent < 0) java.math.BigInteger.ZERO else java.math.BigInteger(literal.substring(exponent + 1)) + } + + private fun isIntegerNumber(candidate: JsonElement): kotlin.Boolean { + val literal = candidate.asString + val significand = numberSignificand(literal) + return significand.signum() == 0 || numberExponent(literal).compareTo(java.math.BigInteger.valueOf(significand.scale().toLong())) >= 0 + } + + private fun isBinaryNumber(candidate: JsonElement): kotlin.Boolean { + if (!candidate.isJsonPrimitive || !candidate.asJsonPrimitive.isNumber || !isIntegerNumber(candidate)) { + return false + } + val literal = candidate.asString + val significand = numberSignificand(literal) + if (significand.signum() == 0) { + return true + } + val scale = numberExponent(literal).subtract(java.math.BigInteger.valueOf(significand.scale().toLong())) + if (scale.compareTo(java.math.BigInteger.valueOf(2)) > 0) { + return false + } + val number = significand.unscaledValue().multiply(java.math.BigInteger.TEN.pow(scale.toInt())) + return number.compareTo(java.math.BigInteger.valueOf(-128)) >= 0 && number.compareTo(java.math.BigInteger.valueOf(255)) <= 0 + } + + private fun matchesNumber(candidate: JsonElement, expected: kotlin.String): kotlin.Boolean { + val literal = candidate.asString + val significand = numberSignificand(literal) + val expectedNumber = java.math.BigDecimal(expected).stripTrailingZeros() + if (significand.unscaledValue() != expectedNumber.unscaledValue()) { + return false + } + return significand.signum() == 0 || numberExponent(literal) == java.math.BigInteger.valueOf(significand.scale().toLong() - expectedNumber.scale()) + } +} diff --git a/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/runtime/AdditionalPropertiesAdapterFactory.kt.golden b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/runtime/AdditionalPropertiesAdapterFactory.kt.golden new file mode 100644 index 000000000..a53f499bb --- /dev/null +++ b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/runtime/AdditionalPropertiesAdapterFactory.kt.golden @@ -0,0 +1,136 @@ +// Code generated by openapi-nexus. DO NOT EDIT. +// +// Kotlin additional properties — 1.0.0 + +package com.example.sdk.runtime + +import com.google.gson.Gson +import com.google.gson.JsonElement +import com.google.gson.JsonParser +import com.google.gson.JsonSyntaxException +import com.google.gson.TypeAdapter +import com.google.gson.TypeAdapterFactory +import com.google.gson.annotations.SerializedName +import com.google.gson.internal.bind.JsonTreeReader +import com.google.gson.internal.bind.JsonTreeWriter +import com.google.gson.reflect.TypeToken +import com.google.gson.stream.JsonReader +import com.google.gson.stream.JsonToken +import com.google.gson.stream.JsonWriter +import java.io.StringReader +import java.io.StringWriter +import java.lang.reflect.Modifier + +/** Internal flat-object support; named fields retain their configured Gson delegate. */ +abstract class AdditionalPropertiesAdapterFactory( + private val modelClass: Class, + private val valueType: TypeToken, + vararg declaredNames: String, +) : TypeAdapterFactory { + private val declaredNames = declaredNames.toSet() + + protected abstract fun properties(value: M): MutableMap + protected abstract fun setProperties(value: M, entries: MutableMap) + protected abstract fun encode(gson: Gson, value: V?): JsonElement + + override fun create(gson: Gson, type: TypeToken): TypeAdapter? { + val modelType: TypeToken = TypeToken.get(modelClass) + if (type.rawType != modelClass) return null + val delegate: TypeAdapter = gson.getDelegateAdapter(this, modelType) + val values: TypeAdapter = gson.getAdapter(valueType) + val trees = gson.getAdapter(JsonElement::class.java) + val known = declaredNames.toMutableSet() + for (field in modelClass.declaredFields) { + if (Modifier.isStatic(field.modifiers) || Modifier.isTransient(field.modifiers)) continue + val annotation = field.getAnnotation(SerializedName::class.java) + if (annotation == null) { + known.add(gson.fieldNamingStrategy().translateName(field)) + } else { + known.add(annotation.value) + known.addAll(annotation.alternate) + } + } + val adapter = object : TypeAdapter() { + override fun read(reader: JsonReader): M? { + if (reader.peek() == JsonToken.NULL) { + reader.nextNull() + return null + } + if (reader.peek() != JsonToken.BEGIN_OBJECT) { + throw JsonSyntaxException("Expected ${modelClass.simpleName} object") + } + val extras = linkedMapOf() + val buffer = StringWriter() + val named = JsonWriter(buffer) + named.serializeNulls = true + named.strictness = reader.strictness + named.beginObject() + reader.beginObject() + while (reader.hasNext()) { + val key = reader.nextName() + if (key in known) { + named.name(key) + trees.write(named, trees.read(reader)) + } else { + // Keep the original reader's native value conversion behavior. + extras[key] = values.read(reader) + } + } + reader.endObject() + named.endObject() + // Tree and streaming readers deliberately retain their own coercion. + val replay = if (reader is JsonTreeReader) { + JsonTreeReader(JsonParser.parseString(buffer.toString())) + } else { + JsonReader(StringReader(buffer.toString())) + } + replay.strictness = reader.strictness + val result = delegate.read(replay) + if (result != null) { + // Gson may allocate without running the Kotlin constructor. + setProperties(result, extras) + } + return result + } + + override fun write(writer: JsonWriter, value: M?) { + if (value == null) { + writer.nullValue() + return + } + val extras = properties(value) + for (key in extras.keys) { + require(key !in known) { "Additional property collides with declared wire field: $key" } + } + // Native trees preserve configured numeric values such as NaN. + val named = JsonTreeWriter() + named.serializeNulls = writer.serializeNulls + named.isHtmlSafe = writer.isHtmlSafe + named.strictness = writer.strictness + delegate.write(named, value) + val fields = named.get().asJsonObject + writer.beginObject() + for ((key, tree) in fields.entrySet()) { + writer.name(key) + trees.write(writer, tree) + } + for ((key, item) in extras) { + require(!fields.has(key)) { "Additional property collides with declared wire field: $key" } + val tree = encode(gson, item) + writer.name(key) + val serializeNulls = writer.serializeNulls + writer.serializeNulls = true + try { + trees.write(writer, tree) + } finally { + writer.serializeNulls = serializeNulls + } + } + writer.endObject() + } + } + // The checked raw-type identity proves this cast for non-generic models. + @Suppress("UNCHECKED_CAST") + return adapter as TypeAdapter + } +} diff --git a/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/runtime/ApiClient.kt.golden b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/runtime/ApiClient.kt.golden new file mode 100644 index 000000000..ae754c3b7 --- /dev/null +++ b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/runtime/ApiClient.kt.golden @@ -0,0 +1,61 @@ +// Code generated by openapi-nexus. DO NOT EDIT. +// +// Kotlin additional properties — 1.0.0 + +package com.example.sdk.runtime + +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response + +class ApiClient( + private val baseUrl: String, + private val client: OkHttpClient = OkHttpClient(), + private val authenticator: Authenticator? = null, + private val defaultHeaders: Map = emptyMap(), +) { + fun newRequest( + method: String, + path: String, + query: Map? = null, + body: String? = null, + ): Request { + return buildRequest(method, path, query, body?.toRequestBody("application/json".toMediaType())) + } + + fun newRequestWithBody( + method: String, + path: String, + query: Map? = null, + body: okhttp3.RequestBody?, + ): Request { + return buildRequest(method, path, query, body) + } + + private fun buildRequest( + method: String, + path: String, + query: Map? = null, + body: okhttp3.RequestBody?, + ): Request { + val urlBuilder: HttpUrl.Builder = (baseUrl.trimEnd('/') + path).toHttpUrl().newBuilder() + query?.forEach { (k, v) -> urlBuilder.addQueryParameter(k, v) } + + val builder = Request.Builder() + .url(urlBuilder.build()) + .method(method, body) + + defaultHeaders.forEach { (k, v) -> builder.header(k, v) } + authenticator?.authenticate(builder) + + return builder.build() + } + + fun execute(request: Request): Response { + return client.newCall(request).execute() + } +} diff --git a/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/runtime/ApiException.kt.golden b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/runtime/ApiException.kt.golden new file mode 100644 index 000000000..94a5d263b --- /dev/null +++ b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/runtime/ApiException.kt.golden @@ -0,0 +1,11 @@ +// Code generated by openapi-nexus. DO NOT EDIT. +// +// Kotlin additional properties — 1.0.0 + +package com.example.sdk.runtime + +open class ApiException( + val statusCode: Int, + val status: String, + val body: String, +) : RuntimeException("API error $statusCode: $status") diff --git a/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/runtime/Auth.kt.golden b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/runtime/Auth.kt.golden new file mode 100644 index 000000000..bf596342f --- /dev/null +++ b/tests/golden/kotlin/kotlin-okhttp/mixed-additional-properties/runtime/Auth.kt.golden @@ -0,0 +1,70 @@ +// Code generated by openapi-nexus. DO NOT EDIT. +// +// Kotlin additional properties — 1.0.0 + +package com.example.sdk.runtime + +import okhttp3.Request + +interface Authenticator { + fun authenticate(builder: Request.Builder) +} + +class BearerAuth : Authenticator { + private val token: String? + private val tokenProvider: (() -> String)? + + constructor(token: String) { + this.token = token + this.tokenProvider = null + } + + constructor(tokenProvider: () -> String) { + this.token = null + this.tokenProvider = tokenProvider + } + + override fun authenticate(builder: Request.Builder) { + val t = tokenProvider?.invoke() ?: token!! + builder.header("Authorization", "Bearer $t") + } +} + +class ApiKeyAuth : Authenticator { + private val key: String? + private val keyProvider: (() -> String)? + private val name: String + private val location: ApiKeyLocation + + constructor(key: String, name: String, location: ApiKeyLocation) { + this.key = key + this.keyProvider = null + this.name = name + this.location = location + } + + constructor(keyProvider: () -> String, name: String, location: ApiKeyLocation) { + this.key = null + this.keyProvider = keyProvider + this.name = name + this.location = location + } + + override fun authenticate(builder: Request.Builder) { + val k = keyProvider?.invoke() ?: key!! + when (location) { + ApiKeyLocation.HEADER -> builder.header(name, k) + ApiKeyLocation.QUERY -> { + val url = builder.build().url.newBuilder() + .addQueryParameter(name, k) + .build() + builder.url(url) + } + } + } +} + +enum class ApiKeyLocation { + HEADER, + QUERY, +} diff --git a/tests/golden/kotlin/kotlin-okhttp/open-tagged-additional-properties/models/EventCompleted.kt.golden b/tests/golden/kotlin/kotlin-okhttp/open-tagged-additional-properties/models/EventCompleted.kt.golden index 316ccd50d..138828e35 100644 --- a/tests/golden/kotlin/kotlin-okhttp/open-tagged-additional-properties/models/EventCompleted.kt.golden +++ b/tests/golden/kotlin/kotlin-okhttp/open-tagged-additional-properties/models/EventCompleted.kt.golden @@ -4,5 +4,41 @@ package com.example.sdk.models -data class EventCompleted(val type: String) { +@com.google.gson.annotations.JsonAdapter(EventCompletedAdditionalPropertiesAdapterFactory::class, nullSafe = false) +data class EventCompleted(val type: String, @field:kotlin.jvm.Transient + var additionalProperties: kotlin.collections.MutableMap = linkedMapOf()) { + constructor(type: kotlin.String) : this(type, linkedMapOf()) { + + } +} + +private class EventCompletedAdditionalPropertiesAdapterFactory : com.example.sdk.runtime.AdditionalPropertiesAdapterFactory { + constructor() : super(com.example.sdk.models.EventCompleted::class.java, object: com.google.gson.reflect.TypeToken() {}, "type") { + + } + + override fun properties(value: com.example.sdk.models.EventCompleted): kotlin.collections.MutableMap { + return value.additionalProperties + } + + override fun setProperties(value: com.example.sdk.models.EventCompleted, + entries: kotlin.collections.MutableMap) { + value.additionalProperties = entries + } + + override fun encode(gson: com.google.gson.Gson, + value: com.google.gson.JsonElement?): com.google.gson.JsonElement { + return additionalValue0(gson, value) + } + + private fun additionalValue0(gson: com.google.gson.Gson, + value: com.google.gson.JsonElement?): com.google.gson.JsonElement { + if (value == null) { + return com.google.gson.JsonNull.INSTANCE + } + return value + } } diff --git a/tests/golden/kotlin/kotlin-okhttp/open-tagged-additional-properties/models/EventSearching.kt.golden b/tests/golden/kotlin/kotlin-okhttp/open-tagged-additional-properties/models/EventSearching.kt.golden index 3066e47c4..a319ef77a 100644 --- a/tests/golden/kotlin/kotlin-okhttp/open-tagged-additional-properties/models/EventSearching.kt.golden +++ b/tests/golden/kotlin/kotlin-okhttp/open-tagged-additional-properties/models/EventSearching.kt.golden @@ -6,6 +6,43 @@ package com.example.sdk.models import com.google.gson.annotations.SerializedName +@com.google.gson.annotations.JsonAdapter(EventSearchingAdditionalPropertiesAdapterFactory::class, nullSafe = false) data class EventSearching(@field:SerializedName("sequence_number") - val sequenceNumber: Int, val type: String) { + val sequenceNumber: Int, val type: String, @field:kotlin.jvm.Transient + var additionalProperties: kotlin.collections.MutableMap = linkedMapOf()) { + constructor(sequenceNumber: kotlin.Int, + type: kotlin.String) : this(sequenceNumber, type, linkedMapOf()) { + + } +} + +private class EventSearchingAdditionalPropertiesAdapterFactory : com.example.sdk.runtime.AdditionalPropertiesAdapterFactory { + constructor() : super(com.example.sdk.models.EventSearching::class.java, object: com.google.gson.reflect.TypeToken() {}, "sequence_number", "type") { + + } + + override fun properties(value: com.example.sdk.models.EventSearching): kotlin.collections.MutableMap { + return value.additionalProperties + } + + override fun setProperties(value: com.example.sdk.models.EventSearching, + entries: kotlin.collections.MutableMap) { + value.additionalProperties = entries + } + + override fun encode(gson: com.google.gson.Gson, + value: com.google.gson.JsonElement?): com.google.gson.JsonElement { + return additionalValue0(gson, value) + } + + private fun additionalValue0(gson: com.google.gson.Gson, + value: com.google.gson.JsonElement?): com.google.gson.JsonElement { + if (value == null) { + return com.google.gson.JsonNull.INSTANCE + } + return value + } } diff --git a/tests/golden/kotlin/kotlin-okhttp/open-tagged-additional-properties/runtime/AdditionalPropertiesAdapterFactory.kt.golden b/tests/golden/kotlin/kotlin-okhttp/open-tagged-additional-properties/runtime/AdditionalPropertiesAdapterFactory.kt.golden new file mode 100644 index 000000000..656186bd2 --- /dev/null +++ b/tests/golden/kotlin/kotlin-okhttp/open-tagged-additional-properties/runtime/AdditionalPropertiesAdapterFactory.kt.golden @@ -0,0 +1,136 @@ +// Code generated by openapi-nexus. DO NOT EDIT. +// +// Open tagged additional properties — 1.0.0 + +package com.example.sdk.runtime + +import com.google.gson.Gson +import com.google.gson.JsonElement +import com.google.gson.JsonParser +import com.google.gson.JsonSyntaxException +import com.google.gson.TypeAdapter +import com.google.gson.TypeAdapterFactory +import com.google.gson.annotations.SerializedName +import com.google.gson.internal.bind.JsonTreeReader +import com.google.gson.internal.bind.JsonTreeWriter +import com.google.gson.reflect.TypeToken +import com.google.gson.stream.JsonReader +import com.google.gson.stream.JsonToken +import com.google.gson.stream.JsonWriter +import java.io.StringReader +import java.io.StringWriter +import java.lang.reflect.Modifier + +/** Internal flat-object support; named fields retain their configured Gson delegate. */ +abstract class AdditionalPropertiesAdapterFactory( + private val modelClass: Class, + private val valueType: TypeToken, + vararg declaredNames: String, +) : TypeAdapterFactory { + private val declaredNames = declaredNames.toSet() + + protected abstract fun properties(value: M): MutableMap + protected abstract fun setProperties(value: M, entries: MutableMap) + protected abstract fun encode(gson: Gson, value: V?): JsonElement + + override fun create(gson: Gson, type: TypeToken): TypeAdapter? { + val modelType: TypeToken = TypeToken.get(modelClass) + if (type.rawType != modelClass) return null + val delegate: TypeAdapter = gson.getDelegateAdapter(this, modelType) + val values: TypeAdapter = gson.getAdapter(valueType) + val trees = gson.getAdapter(JsonElement::class.java) + val known = declaredNames.toMutableSet() + for (field in modelClass.declaredFields) { + if (Modifier.isStatic(field.modifiers) || Modifier.isTransient(field.modifiers)) continue + val annotation = field.getAnnotation(SerializedName::class.java) + if (annotation == null) { + known.add(gson.fieldNamingStrategy().translateName(field)) + } else { + known.add(annotation.value) + known.addAll(annotation.alternate) + } + } + val adapter = object : TypeAdapter() { + override fun read(reader: JsonReader): M? { + if (reader.peek() == JsonToken.NULL) { + reader.nextNull() + return null + } + if (reader.peek() != JsonToken.BEGIN_OBJECT) { + throw JsonSyntaxException("Expected ${modelClass.simpleName} object") + } + val extras = linkedMapOf() + val buffer = StringWriter() + val named = JsonWriter(buffer) + named.serializeNulls = true + named.strictness = reader.strictness + named.beginObject() + reader.beginObject() + while (reader.hasNext()) { + val key = reader.nextName() + if (key in known) { + named.name(key) + trees.write(named, trees.read(reader)) + } else { + // Keep the original reader's native value conversion behavior. + extras[key] = values.read(reader) + } + } + reader.endObject() + named.endObject() + // Tree and streaming readers deliberately retain their own coercion. + val replay = if (reader is JsonTreeReader) { + JsonTreeReader(JsonParser.parseString(buffer.toString())) + } else { + JsonReader(StringReader(buffer.toString())) + } + replay.strictness = reader.strictness + val result = delegate.read(replay) + if (result != null) { + // Gson may allocate without running the Kotlin constructor. + setProperties(result, extras) + } + return result + } + + override fun write(writer: JsonWriter, value: M?) { + if (value == null) { + writer.nullValue() + return + } + val extras = properties(value) + for (key in extras.keys) { + require(key !in known) { "Additional property collides with declared wire field: $key" } + } + // Native trees preserve configured numeric values such as NaN. + val named = JsonTreeWriter() + named.serializeNulls = writer.serializeNulls + named.isHtmlSafe = writer.isHtmlSafe + named.strictness = writer.strictness + delegate.write(named, value) + val fields = named.get().asJsonObject + writer.beginObject() + for ((key, tree) in fields.entrySet()) { + writer.name(key) + trees.write(writer, tree) + } + for ((key, item) in extras) { + require(!fields.has(key)) { "Additional property collides with declared wire field: $key" } + val tree = encode(gson, item) + writer.name(key) + val serializeNulls = writer.serializeNulls + writer.serializeNulls = true + try { + trees.write(writer, tree) + } finally { + writer.serializeNulls = serializeNulls + } + } + writer.endObject() + } + } + // The checked raw-type identity proves this cast for non-generic models. + @Suppress("UNCHECKED_CAST") + return adapter as TypeAdapter + } +} diff --git a/tests/golden_tests_kotlin_okhttp.rs b/tests/golden_tests_kotlin_okhttp.rs index 99efd3703..aca5cf773 100644 --- a/tests/golden_tests_kotlin_okhttp.rs +++ b/tests/golden_tests_kotlin_okhttp.rs @@ -93,6 +93,8 @@ fn get_golden_test_cases() -> HashMap<&'static str, &'static str> { ("reserved-word-fields", "valid/reserved-word-fields.yaml"), ("dictionaries", "valid/kotlin-dictionaries.yaml"), ("dictionary-helper-types", "valid/kotlin-dictionary-helper-types.yaml"), + ("mixed-additional-properties", "valid/kotlin-additional-properties.yaml"), + ("additional-helper-types", "valid/kotlin-additional-helper-types.yaml"), ]) } @@ -188,6 +190,8 @@ generate_golden_tests! { test_reserved_word_fields_golden: "reserved-word-fields", test_dictionaries_golden: "dictionaries", test_dictionary_helper_types_golden: "dictionary-helper-types", + test_mixed_additional_properties_golden: "mixed-additional-properties", + test_additional_helper_types_golden: "additional-helper-types", } #[test] diff --git a/tests/kotlin_additional_properties.rs b/tests/kotlin_additional_properties.rs new file mode 100644 index 000000000..55b8b8370 --- /dev/null +++ b/tests/kotlin_additional_properties.rs @@ -0,0 +1,34 @@ +use openapi_nexus::codegen::traits::code_generator::CodeGenerator; +use openapi_nexus::generators::kotlin::okhttp::KotlinOkhttpCodeGenerator; +use openapi_nexus::ir::{lower::lower, types::IrSchemaKind}; +use openapi_nexus::parser::parse_content_yaml; + +#[test] +fn mixed_extension_facts_get_storage_without_replacing_pure_dictionary_codecs() { + let source = "openapi: 3.1.0\ninfo: {title: Extensions, version: '1'}\npaths: {}\ncomponents:\n schemas:\n Mixed:\n type: object\n required: [known_field]\n properties:\n known_field: {type: string}\n additionalProperties: {type: integer}\n Pure: {type: object, additionalProperties: {type: string}}\n"; + for version in ["3.0.3", "3.1.0", "3.2.0"] { + let ir = lower(parse_content_yaml(&source.replacen("3.1.0", version, 1)).unwrap()).unwrap(); + let IrSchemaKind::Object(object) = &ir.schemas["Mixed"].kind else { + panic!("mixed object facts retained"); + }; + assert!(object.additional_properties.is_some()); + let files = KotlinOkhttpCodeGenerator::new(Default::default()) + .generate(&ir) + .unwrap(); + let mixed = &files + .iter() + .find(|file| file.filename == "Mixed.kt") + .unwrap() + .content; + assert!(mixed.contains("var additionalProperties:")); + assert!(mixed.contains("@field:kotlin.jvm.Transient")); + assert!(mixed.contains("constructor(knownField:")); + let pure = &files + .iter() + .find(|file| file.filename == "Pure.kt") + .unwrap() + .content; + assert!(pure.contains("val additionalProperties:")); + assert!(!pure.contains("AdditionalPropertiesAdapterFactory")); + } +}