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" diff --git a/modules/openapi-generator/pom.xml b/modules/openapi-generator/pom.xml index e3de9d3ee18d..27c2e7fe5fc8 100644 --- a/modules/openapi-generator/pom.xml +++ b/modules/openapi-generator/pom.xml @@ -303,6 +303,31 @@ 2.10.1 test + + + io.gsonfire + gson-fire + 1.9.0 + test + + + com.squareup.okio + okio + 3.2.0 + 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 new file mode 100644 index 000000000000..7c2d9a5ceef4 --- /dev/null +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/JavaOkHttpGsonNullAdditionalPropertiesTest.java @@ -0,0 +1,126 @@ +/* + * 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.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +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.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("supportingFiles", "") + .addGlobalProperty("modelDocs", "false").addGlobalProperty("modelTests", "false"); + new DefaultGenerator().opts(config.toClientOptInput()).generate(); + + // 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(), + jsonSource.toString(), modelSource.toString()), + 0, diagnostics.toString(StandardCharsets.UTF_8)); + + try (URLClassLoader loader = new URLClassLoader(new URL[]{output.toUri().toURL()}, getClass().getClassLoader())) { + 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; + // 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"); + + // 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 + 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()); + } + } +} 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. */