From 00740318abc036488432bf7353d0a7dfe5ed31ce Mon Sep 17 00:00:00 2001 From: Inf1mum Date: Tue, 10 Nov 2026 20:07:37 +0300 Subject: [PATCH 1/5] fix(java): add gson JsonNull regression tests for null additionalProperties The isJsonNull guard in the okhttp-gson CustomTypeAdapterFactory (pojo.mustache) was merged to master via #24573 without referencing #24393 and without regression tests, which left the issue open. Fixes #24393 - generator-level test JavaOkHttpGsonNullAdditionalPropertiesTest: generates an additionalProperties:true model with the java/okhttp-gson generator, compiles the generated CustomTypeAdapterFactory against the real Gson runtime (same fixture approach as JavaOkHttpGsonOneOfPrimitiveArrayTest) and asserts a serialize -> deserialize -> serialize round trip with a null undeclared-property value, plus unchanged behavior for string/array/object entry values - sample test JSONTest.testAdditionalPropertiesNullValue with the petstore AdditionalPropertiesClass repro from the issue No template changes and no sample regeneration required: okhttp-gson samples already carry the guard from #24573, and a targeted regen of bin/configs/java-okhttp-gson.yaml produces no diff. --- ...kHttpGsonNullAdditionalPropertiesTest.java | 134 ++++++++++++++++++ ...khttp-gson-null-additional-properties.yaml | 18 +++ .../org/openapitools/client/JSONTest.java | 33 +++++ 3 files changed, 185 insertions(+) create mode 100644 modules/openapi-generator/src/test/java/org/openapitools/codegen/java/JavaOkHttpGsonNullAdditionalPropertiesTest.java create mode 100644 modules/openapi-generator/src/test/resources/3_0/java/okhttp-gson-null-additional-properties.yaml diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/JavaOkHttpGsonNullAdditionalPropertiesTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/JavaOkHttpGsonNullAdditionalPropertiesTest.java new file mode 100644 index 000000000000..79bf063650f0 --- /dev/null +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/JavaOkHttpGsonNullAdditionalPropertiesTest.java @@ -0,0 +1,134 @@ +/* + * Copyright 2026 OpenAPI-Generator Contributors (https://openapi-generator.tech) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.openapitools.codegen.java; + +import com.github.javaparser.StaticJavaParser; +import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.TypeAdapterFactory; +import org.openapitools.codegen.DefaultGenerator; +import org.openapitools.codegen.config.CodegenConfigurator; +import org.testng.annotations.Test; + +import javax.tools.ToolProvider; +import java.io.ByteArrayOutputStream; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.Collections; +import java.util.Map; + +import static org.openapitools.codegen.TestUtils.newTempFolder; +import static org.testng.Assert.*; + +public class JavaOkHttpGsonNullAdditionalPropertiesTest { + + @Test + public void testNullAdditionalPropertiesRoundTrip() throws Exception { + Path output = newTempFolder(); + CodegenConfigurator config = new CodegenConfigurator().setGeneratorName("java").setLibrary("okhttp-gson") + .setInputSpec("src/test/resources/3_0/java/okhttp-gson-null-additional-properties.yaml") + .setOutputDir(output.toString()) + .addGlobalProperty("models", "").addGlobalProperty("modelDocs", "false") + .addGlobalProperty("modelTests", "false"); + new DefaultGenerator().opts(config.toClientOptInput()).generate(); + ClassOrInterfaceDeclaration modelClass = StaticJavaParser.parse( + output.resolve("src/main/java/org/openapitools/client/model/AdditionalPropertiesModel.java")) + .getClassByName("AdditionalPropertiesModel").orElseThrow(); + ClassOrInterfaceDeclaration factory = modelClass.getMembers().stream() + .filter(m -> m instanceof ClassOrInterfaceDeclaration).map(m -> (ClassOrInterfaceDeclaration) m) + .filter(c -> c.getNameAsString().equals("CustomTypeAdapterFactory")).findFirst().orElseThrow(); + + // Exercise the generated factory with a minimal model stub and the real Gson runtime. + String fixture = "import com.google.gson.*;\nimport com.google.gson.reflect.*;\n" + + "import com.google.gson.stream.*;\nimport java.io.*;\nimport java.lang.reflect.Type;\n" + + "import java.util.*;\n" + + "public class ModelFixture { public static class AdditionalPropertiesModel {\n" + + "private String name;\n" + + "private Map additionalProperties;\n" + + "public String getName() { return name; }\n" + + "public void setName(String name) { this.name = name; }\n" + + "public Map getAdditionalProperties() { return additionalProperties; }\n" + + "public void putAdditionalProperty(String key, Object value) { " + + "if (this.additionalProperties == null) { this.additionalProperties = new HashMap(); } " + + "this.additionalProperties.put(key, value); }\n" + + "public static HashSet openapiFields = new HashSet(Arrays.asList(\"name\"));\n" + + "public static void validateJsonElement(JsonElement jsonElement) throws IOException { }\n" + + factory + "\n" + + "} }\n"; + Path source = output.resolve("ModelFixture.java"); + Files.writeString(source, fixture); + String classpath = Paths.get(Gson.class.getProtectionDomain().getCodeSource().getLocation().toURI()).toString(); + ByteArrayOutputStream diagnostics = new ByteArrayOutputStream(); + assertNotNull(ToolProvider.getSystemJavaCompiler()); + assertEquals(ToolProvider.getSystemJavaCompiler().run(null, diagnostics, diagnostics, + "--release", "8", "-classpath", classpath, "-d", output.toString(), source.toString()), + 0, diagnostics.toString(StandardCharsets.UTF_8)); + + try (URLClassLoader loader = new URLClassLoader(new URL[]{output.toUri().toURL()}, getClass().getClassLoader())) { + Class type = loader.loadClass("ModelFixture$AdditionalPropertiesModel"); + TypeAdapterFactory factoryInstance = (TypeAdapterFactory) loader + .loadClass("ModelFixture$AdditionalPropertiesModel$CustomTypeAdapterFactory") + .getConstructor().newInstance(); + // mirror the ApiClient's Gson, which does not enable serializeNulls + Gson gson = new GsonBuilder().registerTypeAdapterFactory(factoryInstance).create(); + Gson gsonWithNulls = new GsonBuilder().registerTypeAdapterFactory(factoryInstance).serializeNulls().create(); + java.lang.reflect.Method putAdditionalProperty = type.getMethod("putAdditionalProperty", String.class, Object.class); + + // write: a null undeclared-property value used to throw + // IllegalStateException("Not a JSON Object: null") in the CustomTypeAdapterFactory + Object model = type.getConstructor().newInstance(); + type.getMethod("setName", String.class).invoke(model, "x"); + putAdditionalProperty.invoke(model, "unknownField", null); + // JsonElement-level check (serializeNulls enabled, since Gson drops named nulls otherwise) + JsonObject obj = gsonWithNulls.toJsonTree(model, type).getAsJsonObject(); + assertTrue(obj.get("unknownField").isJsonNull(), "A null additional property must be written as JSON null"); + assertEquals(obj.get("name").getAsString(), "x"); + assertNull(obj.get("additionalProperties"), "The raw additionalProperties field must be removed from the output"); + // the ApiClient's writer omits named nulls, but must not throw + assertEquals(gson.toJson(model, type), "{\"name\":\"x\"}"); + assertEquals(gsonWithNulls.toJson(model, type), "{\"name\":\"x\",\"unknownField\":null}"); + + // keep the existing behavior for non-null additional properties + putAdditionalProperty.invoke(model, "extra", "e"); + putAdditionalProperty.invoke(model, "list", Arrays.asList("a", "b")); + putAdditionalProperty.invoke(model, "nested", Collections.singletonMap("k", "v")); + JsonElement tree = gson.toJsonTree(model, type); + obj = tree.getAsJsonObject(); + assertEquals(obj.get("extra").getAsString(), "e"); + assertTrue(obj.get("list").isJsonArray()); + assertTrue(obj.get("nested").isJsonObject()); + + // read: JSON null undeclared properties deserialize into null-valued entries + Object deserialized = gson.fromJson("{\"name\":\"x\",\"unknownField\":null}", type); + assertEquals(type.getMethod("getName").invoke(deserialized), "x"); + Map props = (Map) type.getMethod("getAdditionalProperties").invoke(deserialized); + assertTrue(props.containsKey("unknownField"), "A JSON null undeclared property must be stored"); + assertNull(props.get("unknownField")); + + // serialize -> deserialize -> serialize round trip preserves the null entry + assertEquals(gsonWithNulls.toJson(deserialized, type), "{\"name\":\"x\",\"unknownField\":null}"); + assertEquals(gson.toJson(deserialized, type), "{\"name\":\"x\"}"); + } + } +} diff --git a/modules/openapi-generator/src/test/resources/3_0/java/okhttp-gson-null-additional-properties.yaml b/modules/openapi-generator/src/test/resources/3_0/java/okhttp-gson-null-additional-properties.yaml new file mode 100644 index 000000000000..581cbf5fce6e --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/java/okhttp-gson-null-additional-properties.yaml @@ -0,0 +1,18 @@ +openapi: 3.0.3 +info: {title: t, version: '1'} +paths: + /m: + get: + responses: + '200': + description: ok + content: + application/json: + schema: {$ref: '#/components/schemas/AdditionalPropertiesModel'} +components: + schemas: + AdditionalPropertiesModel: + type: object + properties: + name: {type: string} + additionalProperties: true diff --git a/samples/client/petstore/java/okhttp-gson/src/test/java/org/openapitools/client/JSONTest.java b/samples/client/petstore/java/okhttp-gson/src/test/java/org/openapitools/client/JSONTest.java index 86c22dc25e15..4da140e713ec 100644 --- a/samples/client/petstore/java/okhttp-gson/src/test/java/org/openapitools/client/JSONTest.java +++ b/samples/client/petstore/java/okhttp-gson/src/test/java/org/openapitools/client/JSONTest.java @@ -765,6 +765,39 @@ public void testAdditionalArrayProperties() throws IOException { assertEquals(z.toJson(), "{\"className\":\"zebra\",\"object_array\":[{\"id\":34.0,\"name\":\"just a tag\"}],\"empty_array\":[],\"array\":[\"1\",\"2\",\"3\"],\"new_array\":[\"1\",\"2\",\"3\"],\"new_empty_array\":[],\"new_object_array\":[{\"id\":34,\"name\":\"just a tag\"}]}"); } + /** + * Test additional properties with a null value. + * + * Regression test for https://github.com/OpenAPITools/openapi-generator/issues/24393: + * the CustomTypeAdapterFactory used to throw IllegalStateException("Not a JSON Object: null") + * when serializing a model whose undeclared-property map contains a null value. + */ + @Test + public void testAdditionalPropertiesNullValue() throws Exception { + // serialize: a null undeclared-property value no longer throws and is written as JSON null + AdditionalPropertiesClass model = new AdditionalPropertiesClass(); + model.putAdditionalProperty("unknownField", null); + // Gson drops named nulls unless serializeNulls is enabled + Gson gsonWithNulls = json.getGson().newBuilder().serializeNulls().create(); + JsonObject serialized = gsonWithNulls.toJsonTree(model).getAsJsonObject(); + assertTrue(serialized.get("unknownField").isJsonNull()); + // the ApiClient's Gson does not enable serializeNulls, so the named null is omitted from the text output + assertFalse(model.toJson().contains("unknownField")); + + // deserialize: JSON null undeclared properties are stored as null-valued entries + AdditionalPropertiesClass deserialized = AdditionalPropertiesClass.fromJson("{\"map_property\":{\"key\":\"value\"},\"unknownField\":null}"); + assertEquals(deserialized.getMapProperty(), Collections.singletonMap("key", "value")); + assertTrue(deserialized.getAdditionalProperties().containsKey("unknownField")); + assertNull(deserialized.getAdditionalProperties().get("unknownField")); + + // serialize -> deserialize -> serialize round trip preserves the null entry + JsonObject roundTripped = gsonWithNulls.toJsonTree(deserialized).getAsJsonObject(); + assertEquals(roundTripped.get("map_property").getAsJsonObject().get("key").getAsString(), "value"); + assertTrue(roundTripped.get("unknownField").isJsonNull()); + // text re-serialization does not throw + deserialized.toJson(); + } + /** * Validate a oneOf, anyOf schema with array sub-schema can be deserialized into the expected class. */ From a48efbaedb2c5bd81440f267754826ad4d7443bf Mon Sep 17 00:00:00 2001 From: Inf1mum Date: Tue, 10 Nov 2026 22:38:44 +0300 Subject: [PATCH 2/5] chore: update sample test fingerprint record for JSONTest --- bin/utils/test_file_list.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/utils/test_file_list.yaml b/bin/utils/test_file_list.yaml index 86bd12d5d1bb..3ce25badeb3a 100644 --- a/bin/utils/test_file_list.yaml +++ b/bin/utils/test_file_list.yaml @@ -10,7 +10,7 @@ - filename: "samples/client/petstore/java/okhttp-gson/src/test/java/org/openapitools/client/ClientTest.java" sha256: 325fdd5d7e2c97790c0fb44f712ab7b2ba022d7e1a5b0056f47b07f342682b6d - filename: "samples/client/petstore/java/okhttp-gson/src/test/java/org/openapitools/client/JSONTest.java" - sha256: ddd53d2f640aee7712299f1540779cdb508f0b9c1e96082191686e218eadb04c + sha256: 47293b138c51990bc80299837ed85c00463ccde9c4986fbe51d57246cf5eb0b7 - filename: "samples/client/petstore/java/okhttp-gson/src/test/java/org/openapitools/client/api/PetApiTest.java" sha256: 8b1b8f2a2ad00ccb090873a94a5f73e328b98317d2ec715f53bd7a1accb2a023 - filename: "samples/client/petstore/java/okhttp-gson/src/test/java/org/openapitools/client/model/PetTest.java" From 349bca454fef4fb52cb15a5915af0d5a1bfd4602 Mon Sep 17 00:00:00 2001 From: Inf1mum Date: Wed, 11 Nov 2026 00:23:58 +0300 Subject: [PATCH 3/5] test: run gson null additionalProperties round trip through the client Gson Rework JavaOkHttpGsonNullAdditionalPropertiesTest so the claimed serialize -> deserialize -> serialize round trip is actually exercised: the test generates the client (model + supporting JSON factory), compiles the generated sources against the real Gson runtime (gson-fire, okio and jsr305 added as test-scoped dependencies of the generator module) and takes the Gson from the generated JSON.getGson() instead of a hand-built GsonBuilder over a model stub. The model instance with a null undeclared-property value is serialized, the resulting JSON is deserialized back, the null entry is asserted to survive the round trip and re-serialization is asserted byte-stable. The existing no-throw-on-serialize assertion (the ApiClient's Gson omits named nulls) is kept. --- modules/openapi-generator/pom.xml | 21 +++ ...kHttpGsonNullAdditionalPropertiesTest.java | 124 ++++++++---------- 2 files changed, 79 insertions(+), 66 deletions(-) diff --git a/modules/openapi-generator/pom.xml b/modules/openapi-generator/pom.xml index e3de9d3ee18d..a483982bc90d 100644 --- a/modules/openapi-generator/pom.xml +++ b/modules/openapi-generator/pom.xml @@ -303,6 +303,27 @@ 2.10.1 test + + + io.gsonfire + gson-fire + 1.9.0 + test + + + com.squareup.okio + okio + 3.4.0 + test + + + com.google.code.findbugs + jsr305 + 3.0.2 + test + org.eclipse yasson diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/JavaOkHttpGsonNullAdditionalPropertiesTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/JavaOkHttpGsonNullAdditionalPropertiesTest.java index 79bf063650f0..7c2d9a5ceef4 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/JavaOkHttpGsonNullAdditionalPropertiesTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/JavaOkHttpGsonNullAdditionalPropertiesTest.java @@ -15,23 +15,21 @@ */ package org.openapitools.codegen.java; -import com.github.javaparser.StaticJavaParser; -import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration; import com.google.gson.Gson; -import com.google.gson.GsonBuilder; import com.google.gson.JsonElement; import com.google.gson.JsonObject; -import com.google.gson.TypeAdapterFactory; +import io.gsonfire.GsonFireBuilder; +import okio.ByteString; import org.openapitools.codegen.DefaultGenerator; import org.openapitools.codegen.config.CodegenConfigurator; import org.testng.annotations.Test; import javax.tools.ToolProvider; import java.io.ByteArrayOutputStream; +import java.io.File; import java.net.URL; import java.net.URLClassLoader; import java.nio.charset.StandardCharsets; -import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.Arrays; @@ -49,86 +47,80 @@ public void testNullAdditionalPropertiesRoundTrip() throws Exception { CodegenConfigurator config = new CodegenConfigurator().setGeneratorName("java").setLibrary("okhttp-gson") .setInputSpec("src/test/resources/3_0/java/okhttp-gson-null-additional-properties.yaml") .setOutputDir(output.toString()) - .addGlobalProperty("models", "").addGlobalProperty("modelDocs", "false") - .addGlobalProperty("modelTests", "false"); + .addGlobalProperty("models", "").addGlobalProperty("supportingFiles", "") + .addGlobalProperty("modelDocs", "false").addGlobalProperty("modelTests", "false"); new DefaultGenerator().opts(config.toClientOptInput()).generate(); - ClassOrInterfaceDeclaration modelClass = StaticJavaParser.parse( - output.resolve("src/main/java/org/openapitools/client/model/AdditionalPropertiesModel.java")) - .getClassByName("AdditionalPropertiesModel").orElseThrow(); - ClassOrInterfaceDeclaration factory = modelClass.getMembers().stream() - .filter(m -> m instanceof ClassOrInterfaceDeclaration).map(m -> (ClassOrInterfaceDeclaration) m) - .filter(c -> c.getNameAsString().equals("CustomTypeAdapterFactory")).findFirst().orElseThrow(); - // Exercise the generated factory with a minimal model stub and the real Gson runtime. - String fixture = "import com.google.gson.*;\nimport com.google.gson.reflect.*;\n" - + "import com.google.gson.stream.*;\nimport java.io.*;\nimport java.lang.reflect.Type;\n" - + "import java.util.*;\n" - + "public class ModelFixture { public static class AdditionalPropertiesModel {\n" - + "private String name;\n" - + "private Map additionalProperties;\n" - + "public String getName() { return name; }\n" - + "public void setName(String name) { this.name = name; }\n" - + "public Map getAdditionalProperties() { return additionalProperties; }\n" - + "public void putAdditionalProperty(String key, Object value) { " - + "if (this.additionalProperties == null) { this.additionalProperties = new HashMap(); } " - + "this.additionalProperties.put(key, value); }\n" - + "public static HashSet openapiFields = new HashSet(Arrays.asList(\"name\"));\n" - + "public static void validateJsonElement(JsonElement jsonElement) throws IOException { }\n" - + factory + "\n" - + "} }\n"; - Path source = output.resolve("ModelFixture.java"); - Files.writeString(source, fixture); - String classpath = Paths.get(Gson.class.getProtectionDomain().getCodeSource().getLocation().toURI()).toString(); + // Compile the generated client sources (the JSON factory and the model) against the real + // Gson runtime, mirroring the sample-side JSONTest setup: the model's CustomTypeAdapterFactory + // is registered by the generated JSON class, and JSON.java plus the generated model need the + // client's runtime dependencies (gson-fire, okio, jsr305 @Nullable) on the compile classpath. + Path generatedSource = output.resolve("src/main/java"); + Path jsonSource = generatedSource.resolve("org/openapitools/client/JSON.java"); + Path modelSource = generatedSource.resolve("org/openapitools/client/model/AdditionalPropertiesModel.java"); + String classpath = Paths.get(Gson.class.getProtectionDomain().getCodeSource().getLocation().toURI()) + + File.pathSeparator + + Paths.get(GsonFireBuilder.class.getProtectionDomain().getCodeSource().getLocation().toURI()) + + File.pathSeparator + + Paths.get(ByteString.class.getProtectionDomain().getCodeSource().getLocation().toURI()) + + File.pathSeparator + + Paths.get(javax.annotation.Nullable.class.getProtectionDomain().getCodeSource().getLocation().toURI()) + + File.pathSeparator + + Paths.get(kotlin.jvm.internal.Intrinsics.class.getProtectionDomain().getCodeSource().getLocation().toURI()); ByteArrayOutputStream diagnostics = new ByteArrayOutputStream(); assertNotNull(ToolProvider.getSystemJavaCompiler()); assertEquals(ToolProvider.getSystemJavaCompiler().run(null, diagnostics, diagnostics, - "--release", "8", "-classpath", classpath, "-d", output.toString(), source.toString()), + "--release", "8", "-classpath", classpath, "-d", output.toString(), + jsonSource.toString(), modelSource.toString()), 0, diagnostics.toString(StandardCharsets.UTF_8)); try (URLClassLoader loader = new URLClassLoader(new URL[]{output.toUri().toURL()}, getClass().getClassLoader())) { - Class type = loader.loadClass("ModelFixture$AdditionalPropertiesModel"); - TypeAdapterFactory factoryInstance = (TypeAdapterFactory) loader - .loadClass("ModelFixture$AdditionalPropertiesModel$CustomTypeAdapterFactory") - .getConstructor().newInstance(); - // mirror the ApiClient's Gson, which does not enable serializeNulls - Gson gson = new GsonBuilder().registerTypeAdapterFactory(factoryInstance).create(); - Gson gsonWithNulls = new GsonBuilder().registerTypeAdapterFactory(factoryInstance).serializeNulls().create(); - java.lang.reflect.Method putAdditionalProperty = type.getMethod("putAdditionalProperty", String.class, Object.class); + Class jsonType = loader.loadClass("org.openapitools.client.JSON"); + Class modelType = loader.loadClass("org.openapitools.client.model.AdditionalPropertiesModel"); + // the client's configured Gson (JSON.getGson), not a hand-built one: its static + // initializer registers the generated CustomTypeAdapterFactory for the model + Gson gson = (Gson) jsonType.getMethod("getGson").invoke(null); + // same pattern as the sample JSONTest: serializeNulls so named nulls are written + Gson gsonWithNulls = gson.newBuilder().serializeNulls().create(); + + // build the model with a null undeclared-property value + Object model = modelType.getConstructor().newInstance(); + modelType.getMethod("setName", String.class).invoke(model, "x"); + modelType.getMethod("putAdditionalProperty", String.class, Object.class).invoke(model, "unknownField", null); // write: a null undeclared-property value used to throw - // IllegalStateException("Not a JSON Object: null") in the CustomTypeAdapterFactory - Object model = type.getConstructor().newInstance(); - type.getMethod("setName", String.class).invoke(model, "x"); - putAdditionalProperty.invoke(model, "unknownField", null); - // JsonElement-level check (serializeNulls enabled, since Gson drops named nulls otherwise) - JsonObject obj = gsonWithNulls.toJsonTree(model, type).getAsJsonObject(); + // IllegalStateException("Not a JSON Object: null") in the CustomTypeAdapterFactory; + // the ApiClient's writer omits named nulls, but must not throw + assertEquals(gson.toJson(model, modelType), "{\"name\":\"x\"}"); + + // serialize through the client's Gson and inspect the tree + String serialized = gsonWithNulls.toJson(model, modelType); + assertEquals(serialized, "{\"name\":\"x\",\"unknownField\":null}"); + JsonObject obj = gsonWithNulls.toJsonTree(model, modelType).getAsJsonObject(); assertTrue(obj.get("unknownField").isJsonNull(), "A null additional property must be written as JSON null"); assertEquals(obj.get("name").getAsString(), "x"); assertNull(obj.get("additionalProperties"), "The raw additionalProperties field must be removed from the output"); - // the ApiClient's writer omits named nulls, but must not throw - assertEquals(gson.toJson(model, type), "{\"name\":\"x\"}"); - assertEquals(gsonWithNulls.toJson(model, type), "{\"name\":\"x\",\"unknownField\":null}"); + + // full round trip: deserialize the resulting JSON (not a hand-written string) + Object deserialized = gsonWithNulls.fromJson(serialized, modelType); + assertEquals(modelType.getMethod("getName").invoke(deserialized), "x"); + Map props = (Map) modelType.getMethod("getAdditionalProperties").invoke(deserialized); + assertTrue(props.containsKey("unknownField"), "A JSON null undeclared property must be preserved by the round trip"); + assertNull(props.get("unknownField")); + + // serialize again: the round trip must be byte-stable + assertEquals(gsonWithNulls.toJson(deserialized, modelType), serialized, "Re-serialization must be byte-stable"); + assertEquals(gson.toJson(deserialized, modelType), "{\"name\":\"x\"}"); // keep the existing behavior for non-null additional properties - putAdditionalProperty.invoke(model, "extra", "e"); - putAdditionalProperty.invoke(model, "list", Arrays.asList("a", "b")); - putAdditionalProperty.invoke(model, "nested", Collections.singletonMap("k", "v")); - JsonElement tree = gson.toJsonTree(model, type); + modelType.getMethod("putAdditionalProperty", String.class, Object.class).invoke(model, "extra", "e"); + modelType.getMethod("putAdditionalProperty", String.class, Object.class).invoke(model, "list", Arrays.asList("a", "b")); + modelType.getMethod("putAdditionalProperty", String.class, Object.class).invoke(model, "nested", Collections.singletonMap("k", "v")); + JsonElement tree = gsonWithNulls.toJsonTree(model, modelType); obj = tree.getAsJsonObject(); assertEquals(obj.get("extra").getAsString(), "e"); assertTrue(obj.get("list").isJsonArray()); assertTrue(obj.get("nested").isJsonObject()); - - // read: JSON null undeclared properties deserialize into null-valued entries - Object deserialized = gson.fromJson("{\"name\":\"x\",\"unknownField\":null}", type); - assertEquals(type.getMethod("getName").invoke(deserialized), "x"); - Map props = (Map) type.getMethod("getAdditionalProperties").invoke(deserialized); - assertTrue(props.containsKey("unknownField"), "A JSON null undeclared property must be stored"); - assertNull(props.get("unknownField")); - - // serialize -> deserialize -> serialize round trip preserves the null entry - assertEquals(gsonWithNulls.toJson(deserialized, type), "{\"name\":\"x\",\"unknownField\":null}"); - assertEquals(gson.toJson(deserialized, type), "{\"name\":\"x\"}"); } } } From 1ebfe98b7e437237a87b7ba8480a9c1798654682 Mon Sep 17 00:00:00 2001 From: Inf1mum Date: Wed, 11 Nov 2026 01:06:11 +0300 Subject: [PATCH 4/5] fix(maven): drop direct test-scope jsr305, keep transitive compile-scope provider Declaring com.google.code.findbugs:jsr305 directly at test scope made Maven mediate the transitive compile-scope jsr305 (via guava) down to test scope, so javax.annotation.Nullable disappeared from the main compile classpath and AbstractPythonCodegen, AbstractRustCodegen and K6ClientCodegen failed to compile on JDK 11/17 CI jobs. The generated okhttp-gson sources still resolve javax.annotation.Nullable from the transitive compile-scope artifact, which also covers the test classpath. Verified locally: modules/openapi-generator main compile + the new JavaOkHttpGsonNullAdditionalPropertiesTest pass. Signed-off-by: Inf1mum --- modules/openapi-generator/pom.xml | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/modules/openapi-generator/pom.xml b/modules/openapi-generator/pom.xml index a483982bc90d..089686c40104 100644 --- a/modules/openapi-generator/pom.xml +++ b/modules/openapi-generator/pom.xml @@ -305,7 +305,11 @@ + client sources (JSON.java + model) with the client's configured Gson. + javax.annotation.Nullable (jsr305) is intentionally NOT declared here: + a direct test-scope declaration would mediate the transitive + compile-scope jsr305 from guava down to test scope and break the + main-compile classpath. --> io.gsonfire gson-fire @@ -318,12 +322,6 @@ 3.4.0 test - - com.google.code.findbugs - jsr305 - 3.0.2 - test - org.eclipse yasson From 7bf3c79aa5aa215c29081398d84c4c09618b7818 Mon Sep 17 00:00:00 2001 From: Inf1mum Date: Thu, 12 Nov 2026 22:28:30 +0300 Subject: [PATCH 5/5] fix(maven): pin test okio to 3.2.0 whose Kotlin metadata the test compiler can read okio 3.4.0 (added in 349bca45 as a compile-time runtime dep for the generated okhttp-gson JSON factory) also landed on the module-wide test classpath, which KotlinTestUtils feeds into kotlin-compiler-embeddable 1.6.21 when compiling generated Kotlin. okio 3.4.0 carries Kotlin metadata 1.8.0 and broke KotlinTestUtilsTest, KotlinMicroprofileClientCodegenTest and KotlinSpringServerCodegenTest ('binary version of its metadata is 1.8.0, expected version is 1.6.0'). okio 3.2.0 carries metadata 1.6.0 and keeps the generated-client compile path working. Signed-off-by: Inf1mum --- modules/openapi-generator/pom.xml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/modules/openapi-generator/pom.xml b/modules/openapi-generator/pom.xml index 089686c40104..27c2e7fe5fc8 100644 --- a/modules/openapi-generator/pom.xml +++ b/modules/openapi-generator/pom.xml @@ -309,7 +309,13 @@ javax.annotation.Nullable (jsr305) is intentionally NOT declared here: a direct test-scope declaration would mediate the transitive compile-scope jsr305 from guava down to test scope and break the - main-compile classpath. --> + main-compile classpath. + okio must stay on a version whose Kotlin metadata is readable by the + kotlin-compiler-embeddable 1.6.21 on this module's test classpath + (KotlinTestUtils compiles generated Kotlin with the whole test + classpath as compiler input): okio 3.2.0 carries metadata 1.6.0, + while 3.4.0 carries 1.8.0 and broke KotlinTestUtilsTest, + KotlinMicroprofileClientCodegenTest and KotlinSpringServerCodegenTest. --> io.gsonfire gson-fire @@ -319,7 +325,7 @@ com.squareup.okio okio - 3.4.0 + 3.2.0 test