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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bin/utils/test_file_list.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
25 changes: 25 additions & 0 deletions modules/openapi-generator/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,31 @@
<version>2.10.1</version>
<scope>test</scope>
</dependency>
<!-- runtime dependencies of the generated okhttp-gson JSON factory, so that
JavaOkHttpGsonNullAdditionalPropertiesTest can compile and run generated
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.
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. -->
<dependency>
<groupId>io.gsonfire</groupId>
<artifactId>gson-fire</artifactId>
<version>1.9.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.squareup.okio</groupId>
<artifactId>okio</artifactId>
<version>3.2.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.eclipse</groupId>
<artifactId>yasson</artifactId>
Expand Down
Original file line number Diff line number Diff line change
@@ -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());
}
}
}
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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}");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: deserialized is populated from unrelated hard-coded JSON, so this does not exercise the claimed serialize→deserialize→serialize path. Parse the serialized output instead, and initialize map_property on model if that field must remain asserted.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At samples/client/petstore/java/okhttp-gson/src/test/java/org/openapitools/client/JSONTest.java, line 788:

<comment>`deserialized` is populated from unrelated hard-coded JSON, so this does not exercise the claimed serialize→deserialize→serialize path. Parse the serialized output instead, and initialize `map_property` on `model` if that field must remain asserted.</comment>

<file context>
@@ -765,6 +765,39 @@ public void testAdditionalArrayProperties() throws IOException {
+        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"));
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 349bca4: the generator-side test (JavaOkHttpGsonNullAdditionalPropertiesTest) now runs the full serialize -> deserialize -> serialize round-trip through the client's configured Gson. It 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 builder over a model stub. A model instance with a null-valued additionalProperties entry is serialized, the resulting JSON is fed back into fromJson, the null entry is asserted to survive the round trip, and re-serialization is asserted byte-stable; the existing no-throw-on-serialize assertion is kept. The generator-side test and the sample JSONTest (34 tests) both pass locally.

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.
*/
Expand Down
Loading