From 1b9a9730f6ce2260820a2f80aa65822caad1cfe4 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Wed, 5 Aug 2026 10:09:58 +0200 Subject: [PATCH 01/16] feat(normalizer): enhance custom normalizer class loading with context classloader support --- docs/customization.md | 7 + .../README.adoc | 43 ++++ .../gradle/plugin/OpenApiGeneratorPlugin.kt | 14 ++ .../OpenApiGeneratorGenerateExtension.kt | 27 +++ .../gradle/plugin/tasks/GenerateTask.kt | 16 +- .../kotlin/GeneratorClasspathIsolationTest.kt | 212 ++++++++++++++++++ .../codegen/OpenAPINormalizer.java | 37 ++- .../codegen/OpenAPINormalizerTest.java | 128 +++++++++++ 8 files changed, 480 insertions(+), 4 deletions(-) create mode 100644 modules/openapi-generator-gradle-plugin/src/test/kotlin/GeneratorClasspathIsolationTest.kt diff --git a/docs/customization.md b/docs/customization.md index 66f5aa654d8f..f54bbe7e7582 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -646,6 +646,13 @@ Example: java -jar modules/openapi-generator-cli/target/openapi-generator-cli.jar generate -g java -i modules/openapi-generator/src/test/resources/3_0/required-properties.yaml -o /tmp/java-okhttp/ --openapi-normalizer NORMALIZER_CLASS=org.openapitools.codegen.OpenAPINormalizerTest$RemoveRequiredNormalizer ``` +The class must be resolvable on the generation runtime classpath. When using the +[Gradle plugin](https://github.com/OpenAPITools/openapi-generator/tree/master/modules/openapi-generator-gradle-plugin), +a custom `NORMALIZER_CLASS` that isn't already on the plugin's own classpath must be added via the +`openApiGeneratorExtra` dependency configuration (or the `generatorClasspath` property) so it is forwarded to the +code generation worker in both `workerIsolation` modes (`process` and `classloader`) - see the plugin's README for +details. + - `LOOSE_NULL_DEFINITIONS`: When set to true, allow more schema definitions in OpenAPI 3.0 spec to be the same as `null` in OpenAPI 3.1 spec by setting ModelUtils.looseNullDefinitions to true. Example: diff --git a/modules/openapi-generator-gradle-plugin/README.adoc b/modules/openapi-generator-gradle-plugin/README.adoc index 4ebb4ff66535..a7908b47a89c 100644 --- a/modules/openapi-generator-gradle-plugin/README.adoc +++ b/modules/openapi-generator-gradle-plugin/README.adoc @@ -480,8 +480,51 @@ warning]. |Gradle default (~512 MiB) |Maximum heap size for the forked worker JVM when `workerIsolation` is `process` (e.g. `"512m"`, `"1g"`). Has no effect when `workerIsolation` is `classloader`. + +|generatorClasspath +|ConfigurableFileCollection +|(empty) +a|Additional classpath entries (jars, class directories, project outputs) forwarded to the code generation worker +in *both* `workerIsolation` modes (`process` and `classloader`). Required for any custom class referenced by name +in generator options - most notably a custom `NORMALIZER_CLASS` (see `openapiNormalizer`) - to be resolvable by +the worker, since such classes are not on the plugin's own runtime classpath. + +For dependencies from a repository or another project, prefer adding them to the `openApiGeneratorExtra` +configuration created by this plugin (see below); `generatorClasspath` is a lower-level escape hatch for ad hoc +files/directories: + +[source,groovy] +---- +openApiGenerate { + generatorClasspath.from(files("libs/my-normalizer.jar")) +} +---- |=== +[NOTE] +==== +The plugin creates an `openApiGeneratorExtra` dependency configuration (resolvable, not published) that entries +are automatically forwarded from into `generatorClasspath` for both `workerIsolation` modes. Use it to declare a +custom `NORMALIZER_CLASS` (or any other class referenced by name in generator options) as a normal Gradle +dependency - a published artifact, a local jar, or another project in the same build: + +[source,groovy] +---- +dependencies { + openApiGeneratorExtra("com.acme:my-normalizer:1.0.0") // Maven coordinate + openApiGeneratorExtra(project(":my-normalizer-module")) // project dependency; built automatically + openApiGeneratorExtra(files("libs/my-normalizer.jar")) // local jar/class directory +} + +openApiGenerate { + openapiNormalizer = ["NORMALIZER_CLASS": "com.acme.MyNormalizer"] +} +---- + +Without a corresponding entry in `openApiGeneratorExtra` or `generatorClasspath`, a custom `NORMALIZER_CLASS` will +fail to load with a clear error, regardless of `workerIsolation` mode. +==== + [NOTE] ==== Configuring any one of `apiFilesConstrainedTo`, `modelFilesConstrainedTo`, or `supportingFilesConstrainedTo` results diff --git a/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/OpenApiGeneratorPlugin.kt b/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/OpenApiGeneratorPlugin.kt index d6a889f31291..62e2a439c481 100644 --- a/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/OpenApiGeneratorPlugin.kt +++ b/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/OpenApiGeneratorPlugin.kt @@ -62,6 +62,18 @@ class OpenApiGeneratorPlugin : Plugin { generate.outputDir.convention(layout.buildDirectory.dir("generate-resources/main")) + // A dependency configuration users can add custom classes to (e.g. a jar containing a + // custom NORMALIZER_CLASS) so they are forwarded to the code generation worker's + // classpath, in both "process" and "classloader" workerIsolation modes. Not consumed or + // published; only resolved by this plugin. + val generatorExtraClasspath = configurations.create("openApiGeneratorExtra") { + isVisible = false + isCanBeConsumed = false + isCanBeResolved = true + description = "Additional classpath entries (e.g. custom NORMALIZER_CLASS jars) " + + "forwarded to the openApiGenerate worker in both process and classloader isolation." + } + tasks.apply { register("openApiGenerators", GeneratorsTask::class.java).configure { group = pluginGroup @@ -174,6 +186,8 @@ class OpenApiGeneratorPlugin : Plugin { generateRecursiveDependentModels.set(generate.generateRecursiveDependentModels) workerIsolation.set(generate.workerIsolation) maxWorkerHeapSize.set(generate.maxWorkerHeapSize) + generatorClasspath.from(generatorExtraClasspath) + generatorClasspath.from(generate.generatorClasspath) } } } diff --git a/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/extensions/OpenApiGeneratorGenerateExtension.kt b/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/extensions/OpenApiGeneratorGenerateExtension.kt index c4930819f46c..66c6f36830b2 100644 --- a/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/extensions/OpenApiGeneratorGenerateExtension.kt +++ b/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/extensions/OpenApiGeneratorGenerateExtension.kt @@ -536,6 +536,33 @@ open class OpenApiGeneratorGenerateExtension(private val project: Project) { */ val maxWorkerHeapSize = project.objects.property() + /** + * Additional classpath entries (jars, class directories, project outputs) made visible to the + * code generation worker in *both* [workerIsolation] modes (`process` and `classloader`). + * + * This is the mechanism by which custom classes referenced by name in generator options - + * most notably a custom `NORMALIZER_CLASS` in [openapiNormalizer] - are resolved: such classes + * are not on the plugin's own runtime classpath, so without contributing them here the worker + * (particularly under `process` isolation, which runs in an isolated JVM) cannot load them. + * + * For the common case of depending on a published artifact or another project's output, prefer + * adding a dependency to the `openApiGeneratorExtra` configuration created by this plugin, e.g.: + * ```kotlin + * dependencies { + * openApiGeneratorExtra("com.acme:my-normalizer:1.0.0") + * openApiGeneratorExtra(project(":my-normalizer-module")) + * } + * ``` + * Entries resolved from `openApiGeneratorExtra` are always included automatically; this + * property is an additional, lower-level escape hatch for ad hoc files or directories, e.g.: + * ```kotlin + * openApiGenerate { + * generatorClasspath.from(files("libs/my-normalizer.jar")) + * } + * ``` + */ + val generatorClasspath: ConfigurableFileCollection = project.objects.fileCollection() + init { applyDefaults() } diff --git a/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/tasks/GenerateTask.kt b/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/tasks/GenerateTask.kt index eb36dd800a4c..6be5af425fb4 100644 --- a/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/tasks/GenerateTask.kt +++ b/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/tasks/GenerateTask.kt @@ -321,6 +321,17 @@ abstract class GenerateTask : DefaultTask() { @get:Input abstract val maxWorkerHeapSize: Property + /** + * Additional classpath entries forwarded to the code generation worker in both `process` and + * `classloader` [workerIsolation] modes. Populated by default from the `openApiGeneratorExtra` + * configuration, plus any files/directories added via the `openApiGenerate` extension's + * `generatorClasspath` property. Required for custom classes referenced by name in generator + * options (e.g. a custom `NORMALIZER_CLASS`) to be resolvable by the worker. + */ + @get:Optional + @get:Classpath + abstract val generatorClasspath: ConfigurableFileCollection + /** * The verbosity of generation */ @@ -1148,6 +1159,7 @@ abstract class GenerateTask : DefaultTask() { ) } workerExecutor.processIsolation { + classpath.from(generatorClasspath) maxWorkerHeapSize.orNull?.let { forkOptions.maxHeapSize = it } } } @@ -1160,7 +1172,9 @@ abstract class GenerateTask : DefaultTask() { "consider workerIsolation = \"process\" if you hit metaspace pressure)" ) } - workerExecutor.classLoaderIsolation() + workerExecutor.classLoaderIsolation { + classpath.from(generatorClasspath) + } } else -> throw GradleException("Invalid workerIsolation mode: $isolation. Supported values are 'process' and 'classloader'.") diff --git a/modules/openapi-generator-gradle-plugin/src/test/kotlin/GeneratorClasspathIsolationTest.kt b/modules/openapi-generator-gradle-plugin/src/test/kotlin/GeneratorClasspathIsolationTest.kt new file mode 100644 index 000000000000..34d9b4ac010e --- /dev/null +++ b/modules/openapi-generator-gradle-plugin/src/test/kotlin/GeneratorClasspathIsolationTest.kt @@ -0,0 +1,212 @@ +package org.openapitools.generator.gradle.plugin + +import org.gradle.testkit.runner.GradleRunner +import org.gradle.testkit.runner.TaskOutcome +import org.testng.annotations.Test +import java.io.File +import java.io.FileOutputStream +import java.nio.file.Files +import java.util.jar.JarEntry +import java.util.jar.JarOutputStream +import javax.tools.ToolProvider +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Functional tests verifying that a custom `NORMALIZER_CLASS` (openapiNormalizer rule) is + * resolvable by the code generation worker when it is supplied via either: + * - a dependency on the `openApiGeneratorExtra` configuration created by the plugin, or + * - the `generatorClasspath` property exposed on the `openApiGenerate` extension, + * + * under both `workerIsolation = "process"` and `workerIsolation = "classloader"`. + * + * Regression coverage for: a custom NORMALIZER_CLASS not on the plugin's own runtime classpath + * previously failed to load (most reliably reproducible under "process" isolation, since a + * forked worker JVM only has the plugin's own classpath) because there was no supported way to + * forward a user classpath to the worker in either isolation mode. + */ +class GeneratorClasspathIsolationTest : TestBase() { + + companion object { + private const val NORMALIZER_CLASS_NAME = "com.example.fixture.NoOpNormalizer" + } + + /** + * Compiles a trivial `OpenAPINormalizer` subclass and packages it into a jar file that is + * *not* on the Gradle plugin's own runtime/test classpath, simulating a user-supplied + * normalizer artifact. + */ + private fun buildNormalizerFixtureJar(): File { + val fixtureRoot = Files.createTempDirectory("normalizer-fixture").toFile() + val sourceDir = File(fixtureRoot, "src").apply { mkdirs() } + val classesDir = File(fixtureRoot, "classes").apply { mkdirs() } + + val packageDir = File(sourceDir, "com/example/fixture").apply { mkdirs() } + val sourceFile = File(packageDir, "NoOpNormalizer.java") + sourceFile.writeText( + """ + package com.example.fixture; + + import io.swagger.v3.oas.models.OpenAPI; + import java.util.Map; + + public class NoOpNormalizer extends org.openapitools.codegen.OpenAPINormalizer { + public NoOpNormalizer(OpenAPI openAPI, Map inputRules) { + super(openAPI, inputRules); + } + } + """.trimIndent() + ) + + val compiler = ToolProvider.getSystemJavaCompiler() + val classpath = System.getProperty("java.class.path") + val result = compiler.run( + null, null, null, + "-d", classesDir.absolutePath, + "-cp", classpath, + sourceFile.absolutePath + ) + assertEquals(0, result, "Failed to compile NORMALIZER_CLASS test fixture") + + val jarFile = File(fixtureRoot, "normalizer-fixture.jar") + JarOutputStream(FileOutputStream(jarFile)).use { jar -> + classesDir.walkTopDown().filter { it.isFile }.forEach { classFile -> + val entryName = classFile.relativeTo(classesDir).path.replace(File.separatorChar, '/') + jar.putNextEntry(JarEntry(entryName)) + jar.write(classFile.readBytes()) + jar.closeEntry() + } + } + return jarFile + } + + private fun runOpenApiGenerateExpectingSuccess(buildContents: String): org.gradle.testkit.runner.BuildResult = + GradleRunner.create() + .withProjectDir(temp) + .withArguments("openApiGenerate", "--stacktrace") + .withPluginClasspath() + .also { File(temp, "build.gradle").writeText(buildContents) } + .build() + + private fun copySpec(): File { + val spec = File(temp, "spec.yaml") + javaClass.classLoader.getResourceAsStream("specs/petstore-v3.0.yaml")!!.copyTo(spec.outputStream()) + return spec + } + + // ------------------------------------------------------------------------- + // Negative control: without any extra classpath, a custom NORMALIZER_CLASS not on the + // plugin's classpath must fail with a clear error - guards against a false-positive test. + // ------------------------------------------------------------------------- + + @Test + fun `custom NORMALIZER_CLASS without generatorClasspath fails with a clear error`() { + copySpec() + + // Note: DefaultGenerator logs (but does not fail the build on) NORMALIZER_CLASS load + // failures - this is pre-existing behavior unrelated to this fix. Assert on the log + // output instead of the task outcome. + val result = runOpenApiGenerateExpectingSuccess( + """ + plugins { id 'org.openapi.generator' } + openApiGenerate { + generatorName = "kotlin" + inputSpec = file("spec.yaml").absolutePath + outputDir = file("build/kotlin").absolutePath + openapiNormalizer = ["NORMALIZER_CLASS": "$NORMALIZER_CLASS_NAME"] + } + """.trimIndent() + ) + + assertTrue( + result.output.contains("ClassNotFoundException"), + "Expected a ClassNotFoundException to be reported for the unresolvable NORMALIZER_CLASS, got:\n${result.output}" + ) + assertTrue( + result.output.contains("generatorClasspath") && result.output.contains("openApiGeneratorExtra"), + "Expected the clear error message pointing at generatorClasspath/openApiGeneratorExtra, got:\n${result.output}" + ) + } + + // ------------------------------------------------------------------------- + // openApiGeneratorExtra configuration - process isolation + // ------------------------------------------------------------------------- + + @Test + fun `custom NORMALIZER_CLASS loads via openApiGeneratorExtra configuration under process isolation`() { + copySpec() + val jar = buildNormalizerFixtureJar() + + val result = runOpenApiGenerateExpectingSuccess( + """ + plugins { id 'org.openapi.generator' } + dependencies { + openApiGeneratorExtra(files("${jar.absolutePath.replace("\\", "\\\\")}")) + } + openApiGenerate { + generatorName = "kotlin" + inputSpec = file("spec.yaml").absolutePath + outputDir = file("build/kotlin").absolutePath + openapiNormalizer = ["NORMALIZER_CLASS": "$NORMALIZER_CLASS_NAME"] + workerIsolation = "process" + } + """.trimIndent() + ) + + assertEquals(TaskOutcome.SUCCESS, result.task(":openApiGenerate")?.outcome) + } + + // ------------------------------------------------------------------------- + // openApiGeneratorExtra configuration - classloader isolation + // ------------------------------------------------------------------------- + + @Test + fun `custom NORMALIZER_CLASS loads via openApiGeneratorExtra configuration under classloader isolation`() { + copySpec() + val jar = buildNormalizerFixtureJar() + + val result = runOpenApiGenerateExpectingSuccess( + """ + plugins { id 'org.openapi.generator' } + dependencies { + openApiGeneratorExtra(files("${jar.absolutePath.replace("\\", "\\\\")}")) + } + openApiGenerate { + generatorName = "kotlin" + inputSpec = file("spec.yaml").absolutePath + outputDir = file("build/kotlin").absolutePath + openapiNormalizer = ["NORMALIZER_CLASS": "$NORMALIZER_CLASS_NAME"] + workerIsolation = "classloader" + } + """.trimIndent() + ) + + assertEquals(TaskOutcome.SUCCESS, result.task(":openApiGenerate")?.outcome) + } + + // ------------------------------------------------------------------------- + // generatorClasspath extension property - low-level escape hatch, process isolation + // ------------------------------------------------------------------------- + + @Test + fun `custom NORMALIZER_CLASS loads via generatorClasspath property under process isolation`() { + copySpec() + val jar = buildNormalizerFixtureJar() + + val result = runOpenApiGenerateExpectingSuccess( + """ + plugins { id 'org.openapi.generator' } + openApiGenerate { + generatorName = "kotlin" + inputSpec = file("spec.yaml").absolutePath + outputDir = file("build/kotlin").absolutePath + openapiNormalizer = ["NORMALIZER_CLASS": "$NORMALIZER_CLASS_NAME"] + workerIsolation = "process" + generatorClasspath.from(files("${jar.absolutePath.replace("\\", "\\\\")}")) + } + """.trimIndent() + ) + + assertEquals(TaskOutcome.SUCCESS, result.task(":openApiGenerate")?.outcome) + } +} diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java index 461311648c0c..ed09f5930078 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java @@ -181,18 +181,49 @@ public class OpenAPINormalizer { */ public static OpenAPINormalizer createNormalizer(OpenAPI openAPI, Map inputRules) { if (inputRules.containsKey(NORMALIZER_CLASS)) { + String className = inputRules.get(NORMALIZER_CLASS); try { - Class clazz = Class.forName(inputRules.get(NORMALIZER_CLASS)); - Constructor constructor = clazz.getConstructor(OpenAPI.class, Map.class); + Class clazz = loadNormalizerClass(className); + Constructor constructor = clazz.getConstructor(OpenAPI.class, Map.class); return (OpenAPINormalizer) constructor.newInstance(openAPI, inputRules); } catch (ReflectiveOperationException e) { - throw new RuntimeException(e); + throw new RuntimeException( + "Failed to load custom " + NORMALIZER_CLASS + " '" + className + "'. This class must be " + + "visible on the generation runtime classpath (i.e. resolvable either by the " + + "current thread's context classloader or by the classloader that loaded " + + "openapi-generator itself). When using the Gradle plugin, make sure the class " + + "is provided via the 'openApiGeneratorExtra' dependency configuration or the " + + "'generatorClasspath' property so it is forwarded to the worker in both " + + "'process' and 'classloader' isolation modes.", e); } } else { return new OpenAPINormalizer(openAPI, inputRules); } } + /** + * Loads a custom normalizer class, preferring the current thread's context classloader (which + * frameworks such as Gradle's Worker API set to a classloader that includes any user-supplied + * classpath) and falling back to the classloader that defined {@link OpenAPINormalizer} itself + * (the original, pre-existing behavior) so that normalizers already visible on the default + * classpath keep working unchanged. + * + * @param className fully qualified name of the custom {@link OpenAPINormalizer} subclass + * @return the resolved {@link Class} + * @throws ClassNotFoundException if the class cannot be resolved via either classloader + */ + private static Class loadNormalizerClass(String className) throws ClassNotFoundException { + ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader(); + if (contextClassLoader != null) { + try { + return Class.forName(className, true, contextClassLoader); + } catch (ClassNotFoundException ignored) { + // fall through and try the defining classloader below + } + } + return Class.forName(className, true, OpenAPINormalizer.class.getClassLoader()); + } + /** * Initializes OpenAPI Normalizer with a set of rules * diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/OpenAPINormalizerTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/OpenAPINormalizerTest.java index e9e0ad0d9563..82aacac539bc 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/OpenAPINormalizerTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/OpenAPINormalizerTest.java @@ -26,7 +26,14 @@ import org.openapitools.codegen.utils.ModelUtils; import org.testng.annotations.Test; +import javax.tools.JavaCompiler; +import javax.tools.ToolProvider; +import java.io.File; import java.math.BigDecimal; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.*; import static org.openapitools.codegen.CodegenConstants.X_ENUM_DESCRIPTIONS; @@ -1735,6 +1742,127 @@ public void testNormalizerClass() { assertEquals(requiredProperties.getRequired(), null); } + @Test + public void testNormalizerClassLoadsFromContextClassLoaderWhenNotOnDefaultClassLoader() throws Exception { + // Compile a NORMALIZER_CLASS implementation into an isolated directory that is *not* on + // the classpath used to load OpenAPINormalizerTest/OpenAPINormalizer, then make it + // resolvable only via a custom thread context classloader. This simulates how Gradle's + // Worker API sets the TCCL to a classloader that can see a user-supplied classpath. + Path classesDir = Files.createTempDirectory("normalizer-tccl-test"); + String className = "org.openapitools.codegen.testfixture.TcclOnlyNormalizer"; + compileNormalizerFixture(classesDir, className); + + ClassLoader originalTccl = Thread.currentThread().getContextClassLoader(); + URLClassLoader isolatedLoader = new URLClassLoader( + new URL[]{classesDir.toUri().toURL()}, originalTccl); + try { + Thread.currentThread().setContextClassLoader(isolatedLoader); + + OpenAPI openAPI = TestUtils.parseSpec("src/test/resources/3_0/required-properties.yaml"); + Map inputRules = Map.of("NORMALIZER_CLASS", className); + OpenAPINormalizer openAPINormalizer = OpenAPINormalizer.createNormalizer(openAPI, inputRules); + + assertEquals(openAPINormalizer.getClass().getName(), className); + assertEquals(openAPINormalizer.getClass().getClassLoader(), isolatedLoader); + } finally { + Thread.currentThread().setContextClassLoader(originalTccl); + } + } + + @Test + public void testNormalizerClassFallsBackToDefaultClassLoaderWhenContextClassLoaderIsNull() { + // When the TCCL is null (or cannot resolve the class), createNormalizer must fall back to + // OpenAPINormalizer's own defining classloader, preserving pre-existing behavior. + ClassLoader originalTccl = Thread.currentThread().getContextClassLoader(); + try { + Thread.currentThread().setContextClassLoader(null); + + OpenAPI openAPI = TestUtils.parseSpec("src/test/resources/3_0/required-properties.yaml"); + Map inputRules = Map.of( + "NORMALIZER_CLASS", RemoveRequiredNormalizer.class.getName() + ); + OpenAPINormalizer openAPINormalizer = OpenAPINormalizer.createNormalizer(openAPI, inputRules); + openAPINormalizer.normalize(); + + Schema requiredProperties = openAPI.getComponents().getSchemas().get("RequiredProperties"); + assertEquals(requiredProperties.getRequired(), null); + } finally { + Thread.currentThread().setContextClassLoader(originalTccl); + } + } + + @Test + public void testNormalizerClassFallsBackWhenContextClassLoaderCannotResolveClass() { + // A TCCL that is isolated from the class (e.g. a foreign/unrelated classloader) must not + // prevent resolution: createNormalizer should fall through to the defining classloader. + ClassLoader originalTccl = Thread.currentThread().getContextClassLoader(); + try { + Thread.currentThread().setContextClassLoader(new URLClassLoader(new URL[0], null)); + + OpenAPI openAPI = TestUtils.parseSpec("src/test/resources/3_0/required-properties.yaml"); + Map inputRules = Map.of( + "NORMALIZER_CLASS", RemoveRequiredNormalizer.class.getName() + ); + OpenAPINormalizer openAPINormalizer = OpenAPINormalizer.createNormalizer(openAPI, inputRules); + openAPINormalizer.normalize(); + + Schema requiredProperties = openAPI.getComponents().getSchemas().get("RequiredProperties"); + assertNull(requiredProperties.getRequired()); + } finally { + Thread.currentThread().setContextClassLoader(originalTccl); + } + } + + @Test(expectedExceptions = RuntimeException.class) + public void testNormalizerClassNotFoundProducesClearErrorMessage() { + OpenAPI openAPI = TestUtils.parseSpec("src/test/resources/3_0/required-properties.yaml"); + Map inputRules = Map.of( + "NORMALIZER_CLASS", "org.openapitools.codegen.DoesNotExistNormalizer" + ); + try { + OpenAPINormalizer.createNormalizer(openAPI, inputRules); + } catch (RuntimeException e) { + assertTrue(e.getMessage().contains("generatorClasspath")); + assertTrue(e.getMessage().contains("openApiGeneratorExtra")); + throw e; + } + } + + /** + * Compiles a minimal {@code OpenAPINormalizer} subclass with the given fully qualified class + * name into {@code outputDir}, using only the current JVM's compile-time classpath (so the + * resulting class is a valid {@code NORMALIZER_CLASS} but is not itself present anywhere on + * disk that a default classloader would already see). + */ + private static void compileNormalizerFixture(Path outputDir, String fullyQualifiedClassName) throws Exception { + int lastDot = fullyQualifiedClassName.lastIndexOf('.'); + String packageName = fullyQualifiedClassName.substring(0, lastDot); + String simpleName = fullyQualifiedClassName.substring(lastDot + 1); + + Path sourceDir = Files.createTempDirectory("normalizer-tccl-src"); + Path packageDir = sourceDir.resolve(packageName.replace('.', File.separatorChar)); + Files.createDirectories(packageDir); + Path sourceFile = packageDir.resolve(simpleName + ".java"); + + String source = "package " + packageName + ";\n" + + "import io.swagger.v3.oas.models.OpenAPI;\n" + + "import java.util.Map;\n" + + "public class " + simpleName + " extends org.openapitools.codegen.OpenAPINormalizer {\n" + + " public " + simpleName + "(OpenAPI openAPI, Map inputRules) {\n" + + " super(openAPI, inputRules);\n" + + " }\n" + + "}\n"; + Files.writeString(sourceFile, source); + + JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + String classpath = System.getProperty("java.class.path"); + int result = compiler.run(null, null, null, + "-d", outputDir.toString(), + "-cp", classpath, + sourceFile.toString()); + assertEquals(result, 0, "Failed to compile test fixture normalizer class"); + } + @Test public void testRemoveXInternalFromInlineProperties() { From d16cf1bce7d9d9858660b58bb13ffe21b1faade4 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Wed, 5 Aug 2026 11:21:27 +0200 Subject: [PATCH 02/16] fix: address review feedback on NORMALIZER_CLASS classloader fix - Keep core error message tool-agnostic (no Gradle-specific terms); Gradle-specific guidance remains only in the plugin README/docs. - Add missing test coverage for generatorClasspath + classloader isolation. - Clean up temp directories created by classloader fallback tests to avoid leaking compiled fixture files across test runs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../kotlin/GeneratorClasspathIsolationTest.kt | 30 ++++- .../codegen/OpenAPINormalizer.java | 6 +- .../codegen/OpenAPINormalizerTest.java | 108 ++++++++++++------ 3 files changed, 100 insertions(+), 44 deletions(-) diff --git a/modules/openapi-generator-gradle-plugin/src/test/kotlin/GeneratorClasspathIsolationTest.kt b/modules/openapi-generator-gradle-plugin/src/test/kotlin/GeneratorClasspathIsolationTest.kt index 34d9b4ac010e..33b71cba8eef 100644 --- a/modules/openapi-generator-gradle-plugin/src/test/kotlin/GeneratorClasspathIsolationTest.kt +++ b/modules/openapi-generator-gradle-plugin/src/test/kotlin/GeneratorClasspathIsolationTest.kt @@ -123,8 +123,8 @@ class GeneratorClasspathIsolationTest : TestBase() { "Expected a ClassNotFoundException to be reported for the unresolvable NORMALIZER_CLASS, got:\n${result.output}" ) assertTrue( - result.output.contains("generatorClasspath") && result.output.contains("openApiGeneratorExtra"), - "Expected the clear error message pointing at generatorClasspath/openApiGeneratorExtra, got:\n${result.output}" + result.output.contains(NORMALIZER_CLASS_NAME), + "Expected the clear error message to reference the unresolvable class name, got:\n${result.output}" ) } @@ -209,4 +209,30 @@ class GeneratorClasspathIsolationTest : TestBase() { assertEquals(TaskOutcome.SUCCESS, result.task(":openApiGenerate")?.outcome) } + + // ------------------------------------------------------------------------- + // generatorClasspath extension property - low-level escape hatch, classloader isolation + // ------------------------------------------------------------------------- + + @Test + fun `custom NORMALIZER_CLASS loads via generatorClasspath property under classloader isolation`() { + copySpec() + val jar = buildNormalizerFixtureJar() + + val result = runOpenApiGenerateExpectingSuccess( + """ + plugins { id 'org.openapi.generator' } + openApiGenerate { + generatorName = "kotlin" + inputSpec = file("spec.yaml").absolutePath + outputDir = file("build/kotlin").absolutePath + openapiNormalizer = ["NORMALIZER_CLASS": "$NORMALIZER_CLASS_NAME"] + workerIsolation = "classloader" + generatorClasspath.from(files("${jar.absolutePath.replace("\\", "\\\\")}")) + } + """.trimIndent() + ) + + assertEquals(TaskOutcome.SUCCESS, result.task(":openApiGenerate")?.outcome) + } } diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java index ed09f5930078..5a8792a7dfde 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java @@ -191,10 +191,8 @@ public static OpenAPINormalizer createNormalizer(OpenAPI openAPI, Map inputRules = Map.of("NORMALIZER_CLASS", className); - OpenAPINormalizer openAPINormalizer = OpenAPINormalizer.createNormalizer(openAPI, inputRules); - - assertEquals(openAPINormalizer.getClass().getName(), className); - assertEquals(openAPINormalizer.getClass().getClassLoader(), isolatedLoader); + String className = "org.openapitools.codegen.testfixture.TcclOnlyNormalizer"; + compileNormalizerFixture(classesDir, className); + + ClassLoader originalTccl = Thread.currentThread().getContextClassLoader(); + URLClassLoader isolatedLoader = new URLClassLoader( + new URL[]{classesDir.toUri().toURL()}, originalTccl); + try { + Thread.currentThread().setContextClassLoader(isolatedLoader); + + OpenAPI openAPI = TestUtils.parseSpec("src/test/resources/3_0/required-properties.yaml"); + Map inputRules = Map.of("NORMALIZER_CLASS", className); + OpenAPINormalizer openAPINormalizer = OpenAPINormalizer.createNormalizer(openAPI, inputRules); + + assertEquals(openAPINormalizer.getClass().getName(), className); + assertEquals(openAPINormalizer.getClass().getClassLoader(), isolatedLoader); + } finally { + Thread.currentThread().setContextClassLoader(originalTccl); + isolatedLoader.close(); + } } finally { - Thread.currentThread().setContextClassLoader(originalTccl); + deleteRecursively(classesDir); } } @@ -1822,8 +1829,8 @@ public void testNormalizerClassNotFoundProducesClearErrorMessage() { try { OpenAPINormalizer.createNormalizer(openAPI, inputRules); } catch (RuntimeException e) { - assertTrue(e.getMessage().contains("generatorClasspath")); - assertTrue(e.getMessage().contains("openApiGeneratorExtra")); + assertTrue(e.getMessage().contains("org.openapitools.codegen.DoesNotExistNormalizer")); + assertTrue(e.getMessage().contains("classpath")); throw e; } } @@ -1840,27 +1847,52 @@ private static void compileNormalizerFixture(Path outputDir, String fullyQualifi String simpleName = fullyQualifiedClassName.substring(lastDot + 1); Path sourceDir = Files.createTempDirectory("normalizer-tccl-src"); - Path packageDir = sourceDir.resolve(packageName.replace('.', File.separatorChar)); - Files.createDirectories(packageDir); - Path sourceFile = packageDir.resolve(simpleName + ".java"); - - String source = "package " + packageName + ";\n" - + "import io.swagger.v3.oas.models.OpenAPI;\n" - + "import java.util.Map;\n" - + "public class " + simpleName + " extends org.openapitools.codegen.OpenAPINormalizer {\n" - + " public " + simpleName + "(OpenAPI openAPI, Map inputRules) {\n" - + " super(openAPI, inputRules);\n" - + " }\n" - + "}\n"; - Files.writeString(sourceFile, source); - - JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); - String classpath = System.getProperty("java.class.path"); - int result = compiler.run(null, null, null, - "-d", outputDir.toString(), - "-cp", classpath, - sourceFile.toString()); - assertEquals(result, 0, "Failed to compile test fixture normalizer class"); + try { + Path packageDir = sourceDir.resolve(packageName.replace('.', File.separatorChar)); + Files.createDirectories(packageDir); + Path sourceFile = packageDir.resolve(simpleName + ".java"); + + String source = "package " + packageName + ";\n" + + "import io.swagger.v3.oas.models.OpenAPI;\n" + + "import java.util.Map;\n" + + "public class " + simpleName + " extends org.openapitools.codegen.OpenAPINormalizer {\n" + + " public " + simpleName + "(OpenAPI openAPI, Map inputRules) {\n" + + " super(openAPI, inputRules);\n" + + " }\n" + + "}\n"; + Files.writeString(sourceFile, source); + + JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + String classpath = System.getProperty("java.class.path"); + int result = compiler.run(null, null, null, + "-d", outputDir.toString(), + "-cp", classpath, + sourceFile.toString()); + assertEquals(result, 0, "Failed to compile test fixture normalizer class"); + } finally { + deleteRecursively(sourceDir); + } + } + + /** + * Recursively deletes a temporary directory tree created by the NORMALIZER_CLASS + * classloader-fallback tests, so compiled fixture sources/classes don't leak on disk across + * test runs. + */ + private static void deleteRecursively(Path root) throws IOException { + if (!Files.exists(root)) { + return; + } + try (Stream paths = Files.walk(root)) { + paths.sorted(Comparator.reverseOrder()) + .forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (IOException ignored) { + // best-effort cleanup + } + }); + } } From 4c11967649fbf43a03a6ef98511f71a79d48da7f Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Wed, 5 Aug 2026 11:35:42 +0200 Subject: [PATCH 03/16] docs: document NORMALIZER_CLASS classpath requirement on openapiNormalizer KDoc Cross-reference generatorClasspath/openApiGeneratorExtra directly on the openapiNormalizer property (both extension and task) so the requirement is discoverable from IDE tooltips/KDoc, not just the README. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../plugin/extensions/OpenApiGeneratorGenerateExtension.kt | 5 +++++ .../generator/gradle/plugin/tasks/GenerateTask.kt | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/extensions/OpenApiGeneratorGenerateExtension.kt b/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/extensions/OpenApiGeneratorGenerateExtension.kt index 66c6f36830b2..c2b2e9a23a17 100644 --- a/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/extensions/OpenApiGeneratorGenerateExtension.kt +++ b/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/extensions/OpenApiGeneratorGenerateExtension.kt @@ -296,6 +296,11 @@ open class OpenApiGeneratorGenerateExtension(private val project: Project) { * Example rules: `REFACTOR_ALLOF_WITH_PROPERTIES_ONLY=true`, * `REMOVE_ANYOF_ONEOF_AND_KEEP_PROPERTIES_ONLY=true`. See the OpenAPI Generator docs for * the full list of normalizer rules. + * + * For the `NORMALIZER_CLASS` rule (a custom class extending `OpenAPINormalizer`), the class + * must be added to the [generatorClasspath] (or the `openApiGeneratorExtra` dependency + * configuration) so it is resolvable by the code generation worker in both `workerIsolation` + * modes; otherwise it will fail to load with a `ClassNotFoundException`. */ val openapiNormalizer = project.objects.mapProperty() diff --git a/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/tasks/GenerateTask.kt b/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/tasks/GenerateTask.kt index 6be5af425fb4..e97aa734cd47 100644 --- a/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/tasks/GenerateTask.kt +++ b/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/tasks/GenerateTask.kt @@ -704,6 +704,11 @@ abstract class GenerateTask : DefaultTask() { * Example rules: `REFACTOR_ALLOF_WITH_PROPERTIES_ONLY=true`, * `REMOVE_ANYOF_ONEOF_AND_KEEP_PROPERTIES_ONLY=true`. See the OpenAPI Generator docs for * the full list of normalizer rules. + * + * For the `NORMALIZER_CLASS` rule (a custom class extending `OpenAPINormalizer`), the class + * must be added to [generatorClasspath] (or the `openApiGeneratorExtra` dependency + * configuration) so it is resolvable by the code generation worker in both `workerIsolation` + * modes; otherwise it will fail to load with a `ClassNotFoundException`. */ @get:Optional @get:Input From df8885b387b45b80e1df6fbae6bf7b9c8a58a185 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Wed, 5 Aug 2026 12:10:53 +0200 Subject: [PATCH 04/16] fix: address review feedback on normalizer classloading and tests - Split ClassNotFoundException (classpath guidance) from other ReflectiveOperationException cases (constructor/instantiation failures) in OpenAPINormalizer.createNormalizer so the error message matches the actual failure. - Replace redundant expectedExceptions + manual catch/rethrow in OpenAPINormalizerTest with expectThrows. - Clean up normalizer fixture temp directories after each test in GeneratorClasspathIsolationTest (AfterMethod deleteRecursively). - Assert on the wrapped classpath-guidance log message in addition to the raw ClassNotFoundException text, reducing coupling to log format. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../kotlin/GeneratorClasspathIsolationTest.kt | 16 ++++++++++++++++ .../openapitools/codegen/OpenAPINormalizer.java | 16 ++++++++++++---- .../codegen/OpenAPINormalizerTest.java | 13 +++++-------- 3 files changed, 33 insertions(+), 12 deletions(-) diff --git a/modules/openapi-generator-gradle-plugin/src/test/kotlin/GeneratorClasspathIsolationTest.kt b/modules/openapi-generator-gradle-plugin/src/test/kotlin/GeneratorClasspathIsolationTest.kt index 33b71cba8eef..3bac094100ff 100644 --- a/modules/openapi-generator-gradle-plugin/src/test/kotlin/GeneratorClasspathIsolationTest.kt +++ b/modules/openapi-generator-gradle-plugin/src/test/kotlin/GeneratorClasspathIsolationTest.kt @@ -2,6 +2,7 @@ package org.openapitools.generator.gradle.plugin import org.gradle.testkit.runner.GradleRunner import org.gradle.testkit.runner.TaskOutcome +import org.testng.annotations.AfterMethod import org.testng.annotations.Test import java.io.File import java.io.FileOutputStream @@ -31,6 +32,14 @@ class GeneratorClasspathIsolationTest : TestBase() { private const val NORMALIZER_CLASS_NAME = "com.example.fixture.NoOpNormalizer" } + private val fixtureRoots = mutableListOf() + + @AfterMethod + fun cleanUpFixtureRoots() { + fixtureRoots.forEach { it.deleteRecursively() } + fixtureRoots.clear() + } + /** * Compiles a trivial `OpenAPINormalizer` subclass and packages it into a jar file that is * *not* on the Gradle plugin's own runtime/test classpath, simulating a user-supplied @@ -38,6 +47,7 @@ class GeneratorClasspathIsolationTest : TestBase() { */ private fun buildNormalizerFixtureJar(): File { val fixtureRoot = Files.createTempDirectory("normalizer-fixture").toFile() + fixtureRoots.add(fixtureRoot) val sourceDir = File(fixtureRoot, "src").apply { mkdirs() } val classesDir = File(fixtureRoot, "classes").apply { mkdirs() } @@ -126,6 +136,12 @@ class GeneratorClasspathIsolationTest : TestBase() { result.output.contains(NORMALIZER_CLASS_NAME), "Expected the clear error message to reference the unresolvable class name, got:\n${result.output}" ) + // Additional, more stable marker: our custom normalizer wrapping message should also be present, + // independent of the exact stack trace formatting DefaultGenerator happens to log. + assertTrue( + result.output.contains("Failed to load custom NORMALIZER_CLASS"), + "Expected the wrapped classpath-guidance message to be logged, got:\n${result.output}" + ) } // ------------------------------------------------------------------------- diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java index 5a8792a7dfde..97d3a11a2785 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java @@ -182,11 +182,10 @@ public class OpenAPINormalizer { public static OpenAPINormalizer createNormalizer(OpenAPI openAPI, Map inputRules) { if (inputRules.containsKey(NORMALIZER_CLASS)) { String className = inputRules.get(NORMALIZER_CLASS); + Class clazz; try { - Class clazz = loadNormalizerClass(className); - Constructor constructor = clazz.getConstructor(OpenAPI.class, Map.class); - return (OpenAPINormalizer) constructor.newInstance(openAPI, inputRules); - } catch (ReflectiveOperationException e) { + clazz = loadNormalizerClass(className); + } catch (ClassNotFoundException e) { throw new RuntimeException( "Failed to load custom " + NORMALIZER_CLASS + " '" + className + "'. This class must be " + "visible on the generation runtime classpath (i.e. resolvable either by the " @@ -194,6 +193,15 @@ public static OpenAPINormalizer createNormalizer(OpenAPI openAPI, Map constructor = clazz.getConstructor(OpenAPI.class, Map.class); + return (OpenAPINormalizer) constructor.newInstance(openAPI, inputRules); + } catch (ReflectiveOperationException e) { + throw new RuntimeException( + "Failed to instantiate custom " + NORMALIZER_CLASS + " '" + className + "'. The class was " + + "found but could not be constructed; it must declare a public constructor " + + "accepting (OpenAPI, Map) and that constructor must not throw.", e); + } } else { return new OpenAPINormalizer(openAPI, inputRules); } diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/OpenAPINormalizerTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/OpenAPINormalizerTest.java index 3078a2ed5560..0539b292a12e 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/OpenAPINormalizerTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/OpenAPINormalizerTest.java @@ -1820,19 +1820,16 @@ public void testNormalizerClassFallsBackWhenContextClassLoaderCannotResolveClass } } - @Test(expectedExceptions = RuntimeException.class) + @Test public void testNormalizerClassNotFoundProducesClearErrorMessage() { OpenAPI openAPI = TestUtils.parseSpec("src/test/resources/3_0/required-properties.yaml"); Map inputRules = Map.of( "NORMALIZER_CLASS", "org.openapitools.codegen.DoesNotExistNormalizer" ); - try { - OpenAPINormalizer.createNormalizer(openAPI, inputRules); - } catch (RuntimeException e) { - assertTrue(e.getMessage().contains("org.openapitools.codegen.DoesNotExistNormalizer")); - assertTrue(e.getMessage().contains("classpath")); - throw e; - } + RuntimeException e = expectThrows(RuntimeException.class, + () -> OpenAPINormalizer.createNormalizer(openAPI, inputRules)); + assertTrue(e.getMessage().contains("org.openapitools.codegen.DoesNotExistNormalizer")); + assertTrue(e.getMessage().contains("classpath")); } /** From 81f199f51bc89c1c3c756fe51453411e3e0e3c28 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Wed, 5 Aug 2026 13:33:36 +0200 Subject: [PATCH 05/16] fix: guard runtime compiler use and strengthen positive-path assertions - Skip (not NPE) the TCCL-fixture test when running on a JRE without a system Java compiler (ToolProvider.getSystemJavaCompiler() returns null). - Assert that positive GeneratorClasspathIsolationTest cases show no NORMALIZER_CLASS load failure/ClassNotFoundException in the build log, in addition to TaskOutcome.SUCCESS, since DefaultGenerator logs but does not fail the build on a normalizer load failure. Without this, a regression dropping the forwarded classpath would pass silently. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../kotlin/GeneratorClasspathIsolationTest.kt | 21 ++++++++++++++++--- .../codegen/OpenAPINormalizerTest.java | 4 ++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/modules/openapi-generator-gradle-plugin/src/test/kotlin/GeneratorClasspathIsolationTest.kt b/modules/openapi-generator-gradle-plugin/src/test/kotlin/GeneratorClasspathIsolationTest.kt index 3bac094100ff..2837cee4ec88 100644 --- a/modules/openapi-generator-gradle-plugin/src/test/kotlin/GeneratorClasspathIsolationTest.kt +++ b/modules/openapi-generator-gradle-plugin/src/test/kotlin/GeneratorClasspathIsolationTest.kt @@ -144,6 +144,21 @@ class GeneratorClasspathIsolationTest : TestBase() { ) } + private fun assertNormalizerLoadedSuccessfully(result: org.gradle.testkit.runner.BuildResult) { + assertEquals(TaskOutcome.SUCCESS, result.task(":openApiGenerate")?.outcome) + // Guard against a false-positive SUCCESS: DefaultGenerator only logs (but does not fail + // the build on) a NORMALIZER_CLASS load failure, so a regression that drops the forwarded + // classpath would otherwise leave these tests passing. Assert the failure markers are absent. + assertTrue( + !result.output.contains("Failed to load custom NORMALIZER_CLASS"), + "Did not expect a NORMALIZER_CLASS load failure to be logged, got:\n${result.output}" + ) + assertTrue( + !result.output.contains("ClassNotFoundException"), + "Did not expect a ClassNotFoundException to be logged, got:\n${result.output}" + ) + } + // ------------------------------------------------------------------------- // openApiGeneratorExtra configuration - process isolation // ------------------------------------------------------------------------- @@ -169,7 +184,7 @@ class GeneratorClasspathIsolationTest : TestBase() { """.trimIndent() ) - assertEquals(TaskOutcome.SUCCESS, result.task(":openApiGenerate")?.outcome) + assertNormalizerLoadedSuccessfully(result) } // ------------------------------------------------------------------------- @@ -197,7 +212,7 @@ class GeneratorClasspathIsolationTest : TestBase() { """.trimIndent() ) - assertEquals(TaskOutcome.SUCCESS, result.task(":openApiGenerate")?.outcome) + assertNormalizerLoadedSuccessfully(result) } // ------------------------------------------------------------------------- @@ -223,7 +238,7 @@ class GeneratorClasspathIsolationTest : TestBase() { """.trimIndent() ) - assertEquals(TaskOutcome.SUCCESS, result.task(":openApiGenerate")?.outcome) + assertNormalizerLoadedSuccessfully(result) } // ------------------------------------------------------------------------- diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/OpenAPINormalizerTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/OpenAPINormalizerTest.java index 0539b292a12e..1b75a51b7100 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/OpenAPINormalizerTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/OpenAPINormalizerTest.java @@ -1860,6 +1860,10 @@ private static void compileNormalizerFixture(Path outputDir, String fullyQualifi Files.writeString(sourceFile, source); JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + if (compiler == null) { + throw new org.testng.SkipException( + "No system Java compiler available (test requires a JDK, not a JRE)"); + } String classpath = System.getProperty("java.class.path"); int result = compiler.run(null, null, null, "-d", outputDir.toString(), From daf608b48ae090c5d815dd76fc6fe5496e5d14f9 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Wed, 5 Aug 2026 14:39:19 +0200 Subject: [PATCH 06/16] test: verify NORMALIZER_CLASS actually executed via marker file The fixture normalizer now overrides normalize() to write a marker file (path passed via a MARKER_FILE inputRule) before delegating to super, giving direct proof the custom NORMALIZER_CLASS ran under the worker - across a forked JVM in 'process' isolation - rather than relying only on the build succeeding and no failure text appearing in the log. The negative-control test asserts the marker is absent when the class fails to load, corroborating that normalize() never executed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../kotlin/GeneratorClasspathIsolationTest.kt | 56 +++++++++++++++---- 1 file changed, 46 insertions(+), 10 deletions(-) diff --git a/modules/openapi-generator-gradle-plugin/src/test/kotlin/GeneratorClasspathIsolationTest.kt b/modules/openapi-generator-gradle-plugin/src/test/kotlin/GeneratorClasspathIsolationTest.kt index 2837cee4ec88..0996b84d0448 100644 --- a/modules/openapi-generator-gradle-plugin/src/test/kotlin/GeneratorClasspathIsolationTest.kt +++ b/modules/openapi-generator-gradle-plugin/src/test/kotlin/GeneratorClasspathIsolationTest.kt @@ -58,11 +58,30 @@ class GeneratorClasspathIsolationTest : TestBase() { package com.example.fixture; import io.swagger.v3.oas.models.OpenAPI; + import java.io.IOException; + import java.nio.file.Files; + import java.nio.file.Paths; import java.util.Map; public class NoOpNormalizer extends org.openapitools.codegen.OpenAPINormalizer { + private final Map inputRules; + public NoOpNormalizer(OpenAPI openAPI, Map inputRules) { super(openAPI, inputRules); + this.inputRules = inputRules; + } + + @Override + public void normalize() { + String markerFile = inputRules.get("MARKER_FILE"); + if (markerFile != null) { + try { + Files.writeString(Paths.get(markerFile), "NORMALIZER_RAN"); + } catch (IOException e) { + throw new RuntimeException("Failed to write normalizer marker file", e); + } + } + super.normalize(); } } """.trimIndent() @@ -112,6 +131,7 @@ class GeneratorClasspathIsolationTest : TestBase() { @Test fun `custom NORMALIZER_CLASS without generatorClasspath fails with a clear error`() { copySpec() + val marker = File(temp, "normalizer-ran.marker") // Note: DefaultGenerator logs (but does not fail the build on) NORMALIZER_CLASS load // failures - this is pre-existing behavior unrelated to this fix. Assert on the log @@ -123,7 +143,7 @@ class GeneratorClasspathIsolationTest : TestBase() { generatorName = "kotlin" inputSpec = file("spec.yaml").absolutePath outputDir = file("build/kotlin").absolutePath - openapiNormalizer = ["NORMALIZER_CLASS": "$NORMALIZER_CLASS_NAME"] + openapiNormalizer = ["NORMALIZER_CLASS": "$NORMALIZER_CLASS_NAME", "MARKER_FILE": "${marker.absolutePath.replace("\\", "\\\\")}"] } """.trimIndent() ) @@ -142,9 +162,14 @@ class GeneratorClasspathIsolationTest : TestBase() { result.output.contains("Failed to load custom NORMALIZER_CLASS"), "Expected the wrapped classpath-guidance message to be logged, got:\n${result.output}" ) + // Direct proof the normalizer never ran (in addition to the log-based checks above). + assertTrue( + !marker.exists(), + "Did not expect the normalizer marker file to be created, since NORMALIZER_CLASS could not be loaded" + ) } - private fun assertNormalizerLoadedSuccessfully(result: org.gradle.testkit.runner.BuildResult) { + private fun assertNormalizerLoadedSuccessfully(result: org.gradle.testkit.runner.BuildResult, marker: File) { assertEquals(TaskOutcome.SUCCESS, result.task(":openApiGenerate")?.outcome) // Guard against a false-positive SUCCESS: DefaultGenerator only logs (but does not fail // the build on) a NORMALIZER_CLASS load failure, so a regression that drops the forwarded @@ -157,6 +182,13 @@ class GeneratorClasspathIsolationTest : TestBase() { !result.output.contains("ClassNotFoundException"), "Did not expect a ClassNotFoundException to be logged, got:\n${result.output}" ) + // Direct proof the custom normalizer's normalize() actually executed, rather than relying + // solely on the absence of failure markers above. + assertTrue( + marker.exists(), + "Expected the custom normalizer to have written its marker file, proving it actually ran" + ) + assertEquals("NORMALIZER_RAN", marker.readText()) } // ------------------------------------------------------------------------- @@ -167,6 +199,7 @@ class GeneratorClasspathIsolationTest : TestBase() { fun `custom NORMALIZER_CLASS loads via openApiGeneratorExtra configuration under process isolation`() { copySpec() val jar = buildNormalizerFixtureJar() + val marker = File(temp, "normalizer-ran.marker") val result = runOpenApiGenerateExpectingSuccess( """ @@ -178,13 +211,13 @@ class GeneratorClasspathIsolationTest : TestBase() { generatorName = "kotlin" inputSpec = file("spec.yaml").absolutePath outputDir = file("build/kotlin").absolutePath - openapiNormalizer = ["NORMALIZER_CLASS": "$NORMALIZER_CLASS_NAME"] + openapiNormalizer = ["NORMALIZER_CLASS": "$NORMALIZER_CLASS_NAME", "MARKER_FILE": "${marker.absolutePath.replace("\\", "\\\\")}"] workerIsolation = "process" } """.trimIndent() ) - assertNormalizerLoadedSuccessfully(result) + assertNormalizerLoadedSuccessfully(result, marker) } // ------------------------------------------------------------------------- @@ -195,6 +228,7 @@ class GeneratorClasspathIsolationTest : TestBase() { fun `custom NORMALIZER_CLASS loads via openApiGeneratorExtra configuration under classloader isolation`() { copySpec() val jar = buildNormalizerFixtureJar() + val marker = File(temp, "normalizer-ran.marker") val result = runOpenApiGenerateExpectingSuccess( """ @@ -206,13 +240,13 @@ class GeneratorClasspathIsolationTest : TestBase() { generatorName = "kotlin" inputSpec = file("spec.yaml").absolutePath outputDir = file("build/kotlin").absolutePath - openapiNormalizer = ["NORMALIZER_CLASS": "$NORMALIZER_CLASS_NAME"] + openapiNormalizer = ["NORMALIZER_CLASS": "$NORMALIZER_CLASS_NAME", "MARKER_FILE": "${marker.absolutePath.replace("\\", "\\\\")}"] workerIsolation = "classloader" } """.trimIndent() ) - assertNormalizerLoadedSuccessfully(result) + assertNormalizerLoadedSuccessfully(result, marker) } // ------------------------------------------------------------------------- @@ -223,6 +257,7 @@ class GeneratorClasspathIsolationTest : TestBase() { fun `custom NORMALIZER_CLASS loads via generatorClasspath property under process isolation`() { copySpec() val jar = buildNormalizerFixtureJar() + val marker = File(temp, "normalizer-ran.marker") val result = runOpenApiGenerateExpectingSuccess( """ @@ -231,14 +266,14 @@ class GeneratorClasspathIsolationTest : TestBase() { generatorName = "kotlin" inputSpec = file("spec.yaml").absolutePath outputDir = file("build/kotlin").absolutePath - openapiNormalizer = ["NORMALIZER_CLASS": "$NORMALIZER_CLASS_NAME"] + openapiNormalizer = ["NORMALIZER_CLASS": "$NORMALIZER_CLASS_NAME", "MARKER_FILE": "${marker.absolutePath.replace("\\", "\\\\")}"] workerIsolation = "process" generatorClasspath.from(files("${jar.absolutePath.replace("\\", "\\\\")}")) } """.trimIndent() ) - assertNormalizerLoadedSuccessfully(result) + assertNormalizerLoadedSuccessfully(result, marker) } // ------------------------------------------------------------------------- @@ -249,6 +284,7 @@ class GeneratorClasspathIsolationTest : TestBase() { fun `custom NORMALIZER_CLASS loads via generatorClasspath property under classloader isolation`() { copySpec() val jar = buildNormalizerFixtureJar() + val marker = File(temp, "normalizer-ran.marker") val result = runOpenApiGenerateExpectingSuccess( """ @@ -257,13 +293,13 @@ class GeneratorClasspathIsolationTest : TestBase() { generatorName = "kotlin" inputSpec = file("spec.yaml").absolutePath outputDir = file("build/kotlin").absolutePath - openapiNormalizer = ["NORMALIZER_CLASS": "$NORMALIZER_CLASS_NAME"] + openapiNormalizer = ["NORMALIZER_CLASS": "$NORMALIZER_CLASS_NAME", "MARKER_FILE": "${marker.absolutePath.replace("\\", "\\\\")}"] workerIsolation = "classloader" generatorClasspath.from(files("${jar.absolutePath.replace("\\", "\\\\")}")) } """.trimIndent() ) - assertEquals(TaskOutcome.SUCCESS, result.task(":openApiGenerate")?.outcome) + assertNormalizerLoadedSuccessfully(result, marker) } } From bea8a77baf5694ba086733d550ea316c6f878684 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Wed, 5 Aug 2026 21:59:00 +0200 Subject: [PATCH 07/16] test: guard ToolProvider.getSystemJavaCompiler() null case in Kotlin fixture Mirrors the guard already present in OpenAPINormalizerTest's compileNormalizerFixture: skip with a clear SkipException instead of an opaque NullPointerException when running on a JRE without a system Java compiler. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/test/kotlin/GeneratorClasspathIsolationTest.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/modules/openapi-generator-gradle-plugin/src/test/kotlin/GeneratorClasspathIsolationTest.kt b/modules/openapi-generator-gradle-plugin/src/test/kotlin/GeneratorClasspathIsolationTest.kt index 0996b84d0448..7486793daf2c 100644 --- a/modules/openapi-generator-gradle-plugin/src/test/kotlin/GeneratorClasspathIsolationTest.kt +++ b/modules/openapi-generator-gradle-plugin/src/test/kotlin/GeneratorClasspathIsolationTest.kt @@ -2,6 +2,7 @@ package org.openapitools.generator.gradle.plugin import org.gradle.testkit.runner.GradleRunner import org.gradle.testkit.runner.TaskOutcome +import org.testng.SkipException import org.testng.annotations.AfterMethod import org.testng.annotations.Test import java.io.File @@ -88,6 +89,7 @@ class GeneratorClasspathIsolationTest : TestBase() { ) val compiler = ToolProvider.getSystemJavaCompiler() + ?: throw SkipException("No system Java compiler available (test requires a JDK, not a JRE)") val classpath = System.getProperty("java.class.path") val result = compiler.run( null, null, null, From 3f0d4b99cc562f2faed47c2f7129edb06f4727e6 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Wed, 5 Aug 2026 23:39:08 +0200 Subject: [PATCH 08/16] fix: enhance CodegenConfigLoader to use context class loader and improve error messages --- docs/customization.md | 5 + .../README.adoc | 25 ++- .../kotlin/GeneratorClasspathIsolationTest.kt | 194 ++++++++++++++++++ .../codegen/CodegenConfigLoader.java | 35 +++- .../codegen/CodegenConfigLoaderTest.java | 154 ++++++++++++++ 5 files changed, 398 insertions(+), 15 deletions(-) create mode 100644 modules/openapi-generator/src/test/java/org/openapitools/codegen/CodegenConfigLoaderTest.java diff --git a/docs/customization.md b/docs/customization.md index f54bbe7e7582..e8ef4ca6b852 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -653,6 +653,11 @@ a custom `NORMALIZER_CLASS` that isn't already on the plugin's own classpath mus code generation worker in both `workerIsolation` modes (`process` and `classloader`) - see the plugin's README for details. +Similarly, a custom generator selected by name or fully qualified class name must be resolvable on the generation +runtime classpath. With the Gradle plugin, add its jar or project output to `openApiGeneratorExtra` (preferred) or +`generatorClasspath` so it is forwarded to the code generation worker in both `process` and `classloader` +`workerIsolation` modes. + - `LOOSE_NULL_DEFINITIONS`: When set to true, allow more schema definitions in OpenAPI 3.0 spec to be the same as `null` in OpenAPI 3.1 spec by setting ModelUtils.looseNullDefinitions to true. Example: diff --git a/modules/openapi-generator-gradle-plugin/README.adoc b/modules/openapi-generator-gradle-plugin/README.adoc index a7908b47a89c..6f83414af0ca 100644 --- a/modules/openapi-generator-gradle-plugin/README.adoc +++ b/modules/openapi-generator-gradle-plugin/README.adoc @@ -486,8 +486,9 @@ Has no effect when `workerIsolation` is `classloader`. |(empty) a|Additional classpath entries (jars, class directories, project outputs) forwarded to the code generation worker in *both* `workerIsolation` modes (`process` and `classloader`). Required for any custom class referenced by name -in generator options - most notably a custom `NORMALIZER_CLASS` (see `openapiNormalizer`) - to be resolvable by -the worker, since such classes are not on the plugin's own runtime classpath. +in generator options - most notably a custom `NORMALIZER_CLASS` (see `openapiNormalizer`) or custom generator +selected by `generatorName`/FQCN - to be resolvable by the worker, since such classes are not on the plugin's own +runtime classpath. For dependencies from a repository or another project, prefer adding them to the `openApiGeneratorExtra` configuration created by this plugin (see below); `generatorClasspath` is a lower-level escape hatch for ad hoc @@ -496,7 +497,7 @@ files/directories: [source,groovy] ---- openApiGenerate { - generatorClasspath.from(files("libs/my-normalizer.jar")) + generatorClasspath.from(files("libs/my-normalizer.jar", "libs/my-custom-generator.jar")) } ---- |=== @@ -505,24 +506,28 @@ openApiGenerate { ==== The plugin creates an `openApiGeneratorExtra` dependency configuration (resolvable, not published) that entries are automatically forwarded from into `generatorClasspath` for both `workerIsolation` modes. Use it to declare a -custom `NORMALIZER_CLASS` (or any other class referenced by name in generator options) as a normal Gradle -dependency - a published artifact, a local jar, or another project in the same build: +custom `NORMALIZER_CLASS`, custom generator selected by `generatorName`/FQCN, or any other class referenced by name +in generator options as a normal Gradle dependency - a published artifact, a local jar, or another project in the +same build: [source,groovy] ---- dependencies { - openApiGeneratorExtra("com.acme:my-normalizer:1.0.0") // Maven coordinate - openApiGeneratorExtra(project(":my-normalizer-module")) // project dependency; built automatically - openApiGeneratorExtra(files("libs/my-normalizer.jar")) // local jar/class directory + openApiGeneratorExtra("com.acme:my-normalizer:1.0.0") // custom NORMALIZER_CLASS + openApiGeneratorExtra("com.acme:my-custom-generator:1.0.0") // custom generator + openApiGeneratorExtra(project(":my-generator-module")) // project dependency; built automatically + openApiGeneratorExtra(files("libs/my-normalizer.jar")) // local normalizer jar/class directory + openApiGeneratorExtra(files("libs/my-custom-generator.jar")) // local generator jar/class directory } openApiGenerate { + generatorName = "com.acme.MyGenerator" openapiNormalizer = ["NORMALIZER_CLASS": "com.acme.MyNormalizer"] } ---- -Without a corresponding entry in `openApiGeneratorExtra` or `generatorClasspath`, a custom `NORMALIZER_CLASS` will -fail to load with a clear error, regardless of `workerIsolation` mode. +Without a corresponding entry in `openApiGeneratorExtra` or `generatorClasspath`, a custom `NORMALIZER_CLASS` or +custom generator selected by name/FQCN will fail to load with a clear error, regardless of `workerIsolation` mode. ==== [NOTE] diff --git a/modules/openapi-generator-gradle-plugin/src/test/kotlin/GeneratorClasspathIsolationTest.kt b/modules/openapi-generator-gradle-plugin/src/test/kotlin/GeneratorClasspathIsolationTest.kt index 7486793daf2c..13a9a7dc09c9 100644 --- a/modules/openapi-generator-gradle-plugin/src/test/kotlin/GeneratorClasspathIsolationTest.kt +++ b/modules/openapi-generator-gradle-plugin/src/test/kotlin/GeneratorClasspathIsolationTest.kt @@ -31,6 +31,7 @@ class GeneratorClasspathIsolationTest : TestBase() { companion object { private const val NORMALIZER_CLASS_NAME = "com.example.fixture.NoOpNormalizer" + private const val GENERATOR_CLASS_NAME = "com.example.fixture.MarkerCodegen" } private val fixtureRoots = mutableListOf() @@ -111,6 +112,67 @@ class GeneratorClasspathIsolationTest : TestBase() { return jarFile } + private fun buildGeneratorFixtureJar(): File { + val fixtureRoot = Files.createTempDirectory("generator-fixture").toFile() + fixtureRoots.add(fixtureRoot) + val sourceDir = File(fixtureRoot, "src").apply { mkdirs() } + val classesDir = File(fixtureRoot, "classes").apply { mkdirs() } + + val packageDir = File(sourceDir, "com/example/fixture").apply { mkdirs() } + val sourceFile = File(packageDir, "MarkerCodegen.java") + sourceFile.writeText( + """ + package com.example.fixture; + + import java.io.IOException; + import java.nio.file.Files; + import java.nio.file.Paths; + import org.openapitools.codegen.DefaultCodegen; + + public class MarkerCodegen extends DefaultCodegen { + @Override + public String getName() { + return "marker-codegen"; + } + + @Override + public void processOpts() { + String markerFile = (String) additionalProperties().get("markerFile"); + if (markerFile != null) { + try { + Files.writeString(Paths.get(markerFile), "GENERATOR_RAN"); + } catch (IOException e) { + throw new RuntimeException("Failed to write generator marker file", e); + } + } + super.processOpts(); + } + } + """.trimIndent() + ) + + val compiler = ToolProvider.getSystemJavaCompiler() + ?: throw SkipException("No system Java compiler available (test requires a JDK, not a JRE)") + val result = compiler.run( + null, null, null, + "-d", classesDir.absolutePath, + "-cp", System.getProperty("java.class.path"), + sourceFile.absolutePath + ) + assertEquals(0, result, "Failed to compile custom generator test fixture") + + val jarFile = File(fixtureRoot, "generator-fixture.jar") + JarOutputStream(FileOutputStream(jarFile)).use { jar -> + classesDir.walkTopDown().filter { it.isFile }.forEach { classFile -> + val entryName = classFile.relativeTo(classesDir).path.replace(File.separatorChar, '/') + jar.putNextEntry(JarEntry(entryName)) + jar.write(classFile.readBytes()) + jar.closeEntry() + } + } + return jarFile + } + private fun runOpenApiGenerateExpectingSuccess(buildContents: String): org.gradle.testkit.runner.BuildResult = GradleRunner.create() .withProjectDir(temp) @@ -119,6 +181,14 @@ class GeneratorClasspathIsolationTest : TestBase() { .also { File(temp, "build.gradle").writeText(buildContents) } .build() + private fun runOpenApiGenerateExpectingFailure(buildContents: String): org.gradle.testkit.runner.BuildResult = + GradleRunner.create() + .withProjectDir(temp) + .withArguments("openApiGenerate", "--stacktrace") + .withPluginClasspath() + .also { File(temp, "build.gradle").writeText(buildContents) } + .buildAndFail() + private fun copySpec(): File { val spec = File(temp, "spec.yaml") javaClass.classLoader.getResourceAsStream("specs/petstore-v3.0.yaml")!!.copyTo(spec.outputStream()) @@ -193,6 +263,130 @@ class GeneratorClasspathIsolationTest : TestBase() { assertEquals("NORMALIZER_RAN", marker.readText()) } + @Test + fun `custom generator without generatorClasspath fails with a clear error`() { + copySpec() + val marker = File(temp, "generator-ran.marker") + + val result = runOpenApiGenerateExpectingFailure( + """ + plugins { id 'org.openapi.generator' } + openApiGenerate { + generatorName = "$GENERATOR_CLASS_NAME" + inputSpec = file("spec.yaml").absolutePath + outputDir = file("build/generator").absolutePath + additionalProperties = [markerFile: "${marker.absolutePath.replace("\\", "\\\\")}"] + } + """.trimIndent() + ) + + assertTrue(result.output.contains(GENERATOR_CLASS_NAME)) + assertTrue(result.output.contains("classpath")) + assertTrue(!marker.exists()) + } + + private fun assertGeneratorLoadedSuccessfully(result: org.gradle.testkit.runner.BuildResult, marker: File) { + assertEquals(TaskOutcome.SUCCESS, result.task(":openApiGenerate")?.outcome) + assertTrue(marker.exists(), "Expected the custom generator to write its marker file") + assertEquals("GENERATOR_RAN", marker.readText()) + } + + @Test + fun `custom generator loads via openApiGeneratorExtra configuration under process isolation`() { + copySpec() + val jar = buildGeneratorFixtureJar() + val marker = File(temp, "generator-ran.marker") + + val result = runOpenApiGenerateExpectingSuccess( + """ + plugins { id 'org.openapi.generator' } + dependencies { + openApiGeneratorExtra(files("${jar.absolutePath.replace("\\", "\\\\")}")) + } + openApiGenerate { + generatorName = "$GENERATOR_CLASS_NAME" + inputSpec = file("spec.yaml").absolutePath + outputDir = file("build/generator").absolutePath + additionalProperties = [markerFile: "${marker.absolutePath.replace("\\", "\\\\")}"] + workerIsolation = "process" + } + """.trimIndent() + ) + + assertGeneratorLoadedSuccessfully(result, marker) + } + + @Test + fun `custom generator loads via openApiGeneratorExtra configuration under classloader isolation`() { + copySpec() + val jar = buildGeneratorFixtureJar() + val marker = File(temp, "generator-ran.marker") + + val result = runOpenApiGenerateExpectingSuccess( + """ + plugins { id 'org.openapi.generator' } + dependencies { + openApiGeneratorExtra(files("${jar.absolutePath.replace("\\", "\\\\")}")) + } + openApiGenerate { + generatorName = "$GENERATOR_CLASS_NAME" + inputSpec = file("spec.yaml").absolutePath + outputDir = file("build/generator").absolutePath + additionalProperties = [markerFile: "${marker.absolutePath.replace("\\", "\\\\")}"] + workerIsolation = "classloader" + } + """.trimIndent() + ) + + assertGeneratorLoadedSuccessfully(result, marker) + } + + @Test + fun `custom generator loads via generatorClasspath property under process isolation`() { + copySpec() + val jar = buildGeneratorFixtureJar() + val marker = File(temp, "generator-ran.marker") + + val result = runOpenApiGenerateExpectingSuccess( + """ + plugins { id 'org.openapi.generator' } + openApiGenerate { + generatorName = "$GENERATOR_CLASS_NAME" + inputSpec = file("spec.yaml").absolutePath + outputDir = file("build/generator").absolutePath + additionalProperties = [markerFile: "${marker.absolutePath.replace("\\", "\\\\")}"] + workerIsolation = "process" + generatorClasspath.from(files("${jar.absolutePath.replace("\\", "\\\\")}")) + } + """.trimIndent() + ) + + assertGeneratorLoadedSuccessfully(result, marker) + } + + @Test + fun `custom generator loads via generatorClasspath property under classloader isolation`() { + copySpec() + val jar = buildGeneratorFixtureJar() + val marker = File(temp, "generator-ran.marker") + + val result = runOpenApiGenerateExpectingSuccess( + """ + plugins { id 'org.openapi.generator' } + openApiGenerate { + generatorName = "$GENERATOR_CLASS_NAME" + inputSpec = file("spec.yaml").absolutePath + outputDir = file("build/generator").absolutePath + additionalProperties = [markerFile: "${marker.absolutePath.replace("\\", "\\\\")}"] + workerIsolation = "classloader" + generatorClasspath.from(files("${jar.absolutePath.replace("\\", "\\\\")}")) + } + """.trimIndent() + ) + + assertGeneratorLoadedSuccessfully(result, marker) + } + // ------------------------------------------------------------------------- // openApiGeneratorExtra configuration - process isolation // ------------------------------------------------------------------------- diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConfigLoader.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConfigLoader.java index 64cd2fe921ee..9c691b58e7d5 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConfigLoader.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConfigLoader.java @@ -29,7 +29,7 @@ public class CodegenConfigLoader { * @return config class */ public static CodegenConfig forName(String name) { - ServiceLoader loader = ServiceLoader.load(CodegenConfig.class, CodegenConfig.class.getClassLoader()); + ServiceLoader loader = ServiceLoader.load(CodegenConfig.class, getConfigClassLoader()); StringBuilder availableConfigs = new StringBuilder(); @@ -43,18 +43,43 @@ public static CodegenConfig forName(String name) { // else try to load directly try { - return (CodegenConfig) Class.forName(name).getDeclaredConstructor().newInstance(); - } catch (Exception e) { - throw new GeneratorNotFoundException("Can't load config class with name '".concat(name) + "'\nAvailable:\n" + availableConfigs, e); + return loadConfigClass(name).asSubclass(CodegenConfig.class).getDeclaredConstructor().newInstance(); + } catch (ClassNotFoundException e) { + throw new GeneratorNotFoundException( + "Can't load config class with name '" + name + "'. The class was not found on the generation " + + "runtime classpath. Ensure the class (and its dependencies) is on the classpath used " + + "to launch the generator.\nAvailable:\n" + availableConfigs, e); + } catch (ReflectiveOperationException | ClassCastException e) { + throw new GeneratorNotFoundException( + "Can't instantiate config class with name '" + name + "'. The class was found but could not be " + + "constructed; it must implement CodegenConfig, declare a public no-argument constructor, " + + "and that constructor must not throw.\nAvailable:\n" + availableConfigs, e); } } public static List getAll() { - ServiceLoader loader = ServiceLoader.load(CodegenConfig.class, CodegenConfig.class.getClassLoader()); + ServiceLoader loader = ServiceLoader.load(CodegenConfig.class, getConfigClassLoader()); List output = new ArrayList(); for (CodegenConfig aLoader : loader) { output.add(aLoader); } return output; } + + private static ClassLoader getConfigClassLoader() { + ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader(); + return contextClassLoader != null ? contextClassLoader : CodegenConfig.class.getClassLoader(); + } + + private static Class loadConfigClass(String className) throws ClassNotFoundException { + ClassLoader classLoader = getConfigClassLoader(); + try { + return Class.forName(className, true, classLoader); + } catch (ClassNotFoundException ignored) { + if (classLoader != CodegenConfig.class.getClassLoader()) { + return Class.forName(className, true, CodegenConfig.class.getClassLoader()); + } + throw ignored; + } + } } diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/CodegenConfigLoaderTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/CodegenConfigLoaderTest.java new file mode 100644 index 000000000000..8c071747c4f1 --- /dev/null +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/CodegenConfigLoaderTest.java @@ -0,0 +1,154 @@ +/* + * 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 + * + * https://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; + +import org.testng.SkipException; +import org.testng.annotations.Test; + +import javax.tools.JavaCompiler; +import javax.tools.ToolProvider; +import java.io.File; +import java.io.IOException; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.stream.Stream; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertTrue; +import static org.testng.Assert.expectThrows; + +public class CodegenConfigLoaderTest { + + @Test + public void testConfigClassLoadsFromContextClassLoaderWhenNotOnDefaultClassLoader() throws Exception { + Path classesDir = Files.createTempDirectory("codegen-config-tccl-test"); + try { + String className = "org.openapitools.codegen.testfixture.TcclOnlyCodegen"; + compileCodegenFixture(classesDir, className); + Path serviceFile = classesDir.resolve("META-INF/services/" + CodegenConfig.class.getName()); + Files.createDirectories(serviceFile.getParent()); + Files.writeString(serviceFile, className); + + ClassLoader originalTccl = Thread.currentThread().getContextClassLoader(); + URLClassLoader isolatedLoader = new URLClassLoader( + new URL[]{classesDir.toUri().toURL()}, originalTccl); + try { + Thread.currentThread().setContextClassLoader(isolatedLoader); + + CodegenConfig config = CodegenConfigLoader.forName("tccl-only-codegen"); + CodegenConfig configByClassName = CodegenConfigLoader.forName(className); + + assertEquals(config.getClass().getName(), className); + assertEquals(config.getClass().getClassLoader(), isolatedLoader); + assertEquals(configByClassName.getClass().getClassLoader(), isolatedLoader); + } finally { + Thread.currentThread().setContextClassLoader(originalTccl); + isolatedLoader.close(); + } + } finally { + deleteRecursively(classesDir); + } + } + + @Test + public void testConfigClassFallsBackToDefaultClassLoaderWhenContextClassLoaderIsNull() { + ClassLoader originalTccl = Thread.currentThread().getContextClassLoader(); + try { + Thread.currentThread().setContextClassLoader(null); + + CodegenConfig config = CodegenConfigLoader.forName(DefaultCodegen.class.getName()); + + assertEquals(config.getClass(), DefaultCodegen.class); + } finally { + Thread.currentThread().setContextClassLoader(originalTccl); + } + } + + @Test + public void testConfigClassFallsBackWhenContextClassLoaderCannotResolveClass() throws Exception { + ClassLoader originalTccl = Thread.currentThread().getContextClassLoader(); + URLClassLoader isolatedLoader = new URLClassLoader(new URL[0], null); + try { + Thread.currentThread().setContextClassLoader(isolatedLoader); + + CodegenConfig config = CodegenConfigLoader.forName(DefaultCodegen.class.getName()); + + assertEquals(config.getClass(), DefaultCodegen.class); + } finally { + Thread.currentThread().setContextClassLoader(originalTccl); + isolatedLoader.close(); + } + } + + @Test + public void testConfigClassNotFoundProducesClearErrorMessage() { + GeneratorNotFoundException exception = expectThrows(GeneratorNotFoundException.class, + () -> CodegenConfigLoader.forName("does.not.Exist")); + + assertTrue(exception.getMessage().contains("does.not.Exist")); + assertTrue(exception.getMessage().contains("classpath")); + assertTrue(exception.getMessage().contains("Available:")); + } + + private static void compileCodegenFixture(Path outputDir, String fullyQualifiedClassName) throws Exception { + int lastDot = fullyQualifiedClassName.lastIndexOf('.'); + String packageName = fullyQualifiedClassName.substring(0, lastDot); + String simpleName = fullyQualifiedClassName.substring(lastDot + 1); + Path sourceDir = Files.createTempDirectory("codegen-config-tccl-src"); + try { + Path packageDir = sourceDir.resolve(packageName.replace('.', File.separatorChar)); + Files.createDirectories(packageDir); + Path sourceFile = packageDir.resolve(simpleName + ".java"); + Files.writeString(sourceFile, "package " + packageName + ";\n" + + "public class " + simpleName + " extends org.openapitools.codegen.DefaultCodegen {\n" + + " public " + simpleName + "() {}\n" + + " @Override public String getName() { return \"tccl-only-codegen\"; }\n" + + "}\n"); + + JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + if (compiler == null) { + throw new SkipException("No system Java compiler available (test requires a JDK, not a JRE)"); + } + int result = compiler.run(null, null, null, + "-d", outputDir.toString(), + "-cp", System.getProperty("java.class.path"), + sourceFile.toString()); + assertEquals(result, 0, "Failed to compile test fixture generator class"); + } finally { + deleteRecursively(sourceDir); + } + } + + private static void deleteRecursively(Path root) throws IOException { + if (!Files.exists(root)) { + return; + } + try (Stream paths = Files.walk(root)) { + paths.sorted(Comparator.reverseOrder()) + .forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (IOException ignored) { + // best-effort cleanup + } + }); + } + } +} From 41ec7f7a05ee08698d1b326a94aa03425a50f842 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Wed, 5 Aug 2026 23:46:31 +0200 Subject: [PATCH 09/16] improve test coverage --- .../codegen/CodegenConfigLoaderTest.java | 57 +++++++++++++++---- 1 file changed, 46 insertions(+), 11 deletions(-) diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/CodegenConfigLoaderTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/CodegenConfigLoaderTest.java index 8c071747c4f1..349c2bc6924c 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/CodegenConfigLoaderTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/CodegenConfigLoaderTest.java @@ -30,9 +30,7 @@ import java.util.Comparator; import java.util.stream.Stream; -import static org.testng.Assert.assertEquals; -import static org.testng.Assert.assertTrue; -import static org.testng.Assert.expectThrows; +import static org.testng.Assert.*; public class CodegenConfigLoaderTest { @@ -47,9 +45,8 @@ public void testConfigClassLoadsFromContextClassLoaderWhenNotOnDefaultClassLoade Files.writeString(serviceFile, className); ClassLoader originalTccl = Thread.currentThread().getContextClassLoader(); - URLClassLoader isolatedLoader = new URLClassLoader( - new URL[]{classesDir.toUri().toURL()}, originalTccl); - try { + try (URLClassLoader isolatedLoader = new URLClassLoader( + new URL[]{classesDir.toUri().toURL()}, originalTccl)) { Thread.currentThread().setContextClassLoader(isolatedLoader); CodegenConfig config = CodegenConfigLoader.forName("tccl-only-codegen"); @@ -60,7 +57,6 @@ public void testConfigClassLoadsFromContextClassLoaderWhenNotOnDefaultClassLoade assertEquals(configByClassName.getClass().getClassLoader(), isolatedLoader); } finally { Thread.currentThread().setContextClassLoader(originalTccl); - isolatedLoader.close(); } } finally { deleteRecursively(classesDir); @@ -84,8 +80,7 @@ public void testConfigClassFallsBackToDefaultClassLoaderWhenContextClassLoaderIs @Test public void testConfigClassFallsBackWhenContextClassLoaderCannotResolveClass() throws Exception { ClassLoader originalTccl = Thread.currentThread().getContextClassLoader(); - URLClassLoader isolatedLoader = new URLClassLoader(new URL[0], null); - try { + try (URLClassLoader isolatedLoader = new URLClassLoader(new URL[0], null)) { Thread.currentThread().setContextClassLoader(isolatedLoader); CodegenConfig config = CodegenConfigLoader.forName(DefaultCodegen.class.getName()); @@ -93,7 +88,6 @@ public void testConfigClassFallsBackWhenContextClassLoaderCannotResolveClass() t assertEquals(config.getClass(), DefaultCodegen.class); } finally { Thread.currentThread().setContextClassLoader(originalTccl); - isolatedLoader.close(); } } @@ -107,7 +101,48 @@ public void testConfigClassNotFoundProducesClearErrorMessage() { assertTrue(exception.getMessage().contains("Available:")); } + @Test + public void testFoundClassThatDoesNotImplementCodegenConfigProducesClearErrorMessage() { + GeneratorNotFoundException exception = expectThrows(GeneratorNotFoundException.class, + () -> CodegenConfigLoader.forName(String.class.getName())); + + assertTrue(exception.getMessage().contains(String.class.getName())); + assertTrue(exception.getMessage().contains("found but could not be constructed")); + assertTrue(exception.getMessage().contains("implement CodegenConfig")); + } + + @Test + public void testFoundConfigClassWithoutPublicNoArgConstructorProducesClearErrorMessage() throws Exception { + Path classesDir = Files.createTempDirectory("codegen-config-constructor-test"); + try { + String className = "org.openapitools.codegen.testfixture.PrivateConstructorCodegen"; + compileCodegenFixture(classesDir, className, false); + + ClassLoader originalTccl = Thread.currentThread().getContextClassLoader(); + try (URLClassLoader isolatedLoader = new URLClassLoader( + new URL[]{classesDir.toUri().toURL()}, originalTccl)) { + Thread.currentThread().setContextClassLoader(isolatedLoader); + + GeneratorNotFoundException exception = expectThrows(GeneratorNotFoundException.class, + () -> CodegenConfigLoader.forName(className)); + + assertTrue(exception.getMessage().contains(className)); + assertTrue(exception.getMessage().contains("found but could not be constructed")); + assertTrue(exception.getMessage().contains("public no-argument constructor")); + } finally { + Thread.currentThread().setContextClassLoader(originalTccl); + } + } finally { + deleteRecursively(classesDir); + } + } + private static void compileCodegenFixture(Path outputDir, String fullyQualifiedClassName) throws Exception { + compileCodegenFixture(outputDir, fullyQualifiedClassName, true); + } + + private static void compileCodegenFixture(Path outputDir, String fullyQualifiedClassName, + boolean publicNoArgConstructor) throws Exception { int lastDot = fullyQualifiedClassName.lastIndexOf('.'); String packageName = fullyQualifiedClassName.substring(0, lastDot); String simpleName = fullyQualifiedClassName.substring(lastDot + 1); @@ -118,7 +153,7 @@ private static void compileCodegenFixture(Path outputDir, String fullyQualifiedC Path sourceFile = packageDir.resolve(simpleName + ".java"); Files.writeString(sourceFile, "package " + packageName + ";\n" + "public class " + simpleName + " extends org.openapitools.codegen.DefaultCodegen {\n" - + " public " + simpleName + "() {}\n" + + " " + (publicNoArgConstructor ? "public" : "private") + " " + simpleName + "() {}\n" + " @Override public String getName() { return \"tccl-only-codegen\"; }\n" + "}\n"); From dd5d2741b49b1cdb96755bf34be871f5b913a4c6 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Wed, 5 Aug 2026 23:53:28 +0200 Subject: [PATCH 10/16] fix: update compileCodegenFixture to accept generator name parameter --- .../org/openapitools/codegen/CodegenConfigLoaderTest.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/CodegenConfigLoaderTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/CodegenConfigLoaderTest.java index 349c2bc6924c..8d12646c48c3 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/CodegenConfigLoaderTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/CodegenConfigLoaderTest.java @@ -116,7 +116,7 @@ public void testFoundConfigClassWithoutPublicNoArgConstructorProducesClearErrorM Path classesDir = Files.createTempDirectory("codegen-config-constructor-test"); try { String className = "org.openapitools.codegen.testfixture.PrivateConstructorCodegen"; - compileCodegenFixture(classesDir, className, false); + compileCodegenFixture(classesDir, className, false, "private-constructor-codegen"); ClassLoader originalTccl = Thread.currentThread().getContextClassLoader(); try (URLClassLoader isolatedLoader = new URLClassLoader( @@ -138,11 +138,11 @@ public void testFoundConfigClassWithoutPublicNoArgConstructorProducesClearErrorM } private static void compileCodegenFixture(Path outputDir, String fullyQualifiedClassName) throws Exception { - compileCodegenFixture(outputDir, fullyQualifiedClassName, true); + compileCodegenFixture(outputDir, fullyQualifiedClassName, true, "tccl-only-codegen"); } private static void compileCodegenFixture(Path outputDir, String fullyQualifiedClassName, - boolean publicNoArgConstructor) throws Exception { + boolean publicNoArgConstructor, String generatorName) throws Exception { int lastDot = fullyQualifiedClassName.lastIndexOf('.'); String packageName = fullyQualifiedClassName.substring(0, lastDot); String simpleName = fullyQualifiedClassName.substring(lastDot + 1); @@ -154,7 +154,7 @@ private static void compileCodegenFixture(Path outputDir, String fullyQualifiedC Files.writeString(sourceFile, "package " + packageName + ";\n" + "public class " + simpleName + " extends org.openapitools.codegen.DefaultCodegen {\n" + " " + (publicNoArgConstructor ? "public" : "private") + " " + simpleName + "() {}\n" - + " @Override public String getName() { return \"tccl-only-codegen\"; }\n" + + " @Override public String getName() { return \"" + generatorName + "\"; }\n" + "}\n"); JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); From 1696a235996d954d8801fe5abac668090570f552 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Thu, 6 Aug 2026 00:21:46 +0200 Subject: [PATCH 11/16] fix: enhance CodegenConfigLoader to improve class loading and error handling --- .../kotlin/GeneratorClasspathIsolationTest.kt | 108 ++++++++---------- .../codegen/CodegenConfigLoader.java | 42 +++++-- .../codegen/CodegenConfigLoaderTest.java | 41 ++++++- 3 files changed, 116 insertions(+), 75 deletions(-) diff --git a/modules/openapi-generator-gradle-plugin/src/test/kotlin/GeneratorClasspathIsolationTest.kt b/modules/openapi-generator-gradle-plugin/src/test/kotlin/GeneratorClasspathIsolationTest.kt index 13a9a7dc09c9..6b02fe70f0d4 100644 --- a/modules/openapi-generator-gradle-plugin/src/test/kotlin/GeneratorClasspathIsolationTest.kt +++ b/modules/openapi-generator-gradle-plugin/src/test/kotlin/GeneratorClasspathIsolationTest.kt @@ -42,20 +42,52 @@ class GeneratorClasspathIsolationTest : TestBase() { fixtureRoots.clear() } + private fun buildFixtureJar( + fixtureName: String, + sourceFileName: String, + source: String, + compilationFailureMessage: String + ): File { + val fixtureRoot = Files.createTempDirectory("$fixtureName-fixture").toFile() + fixtureRoots.add(fixtureRoot) + val sourceDir = File(fixtureRoot, "src").apply { mkdirs() } + val classesDir = File(fixtureRoot, "classes").apply { mkdirs() } + val sourceFile = File(sourceDir, "com/example/fixture/$sourceFileName").apply { + parentFile.mkdirs() + writeText(source) + } + + val compiler = ToolProvider.getSystemJavaCompiler() + ?: throw SkipException("No system Java compiler available (test requires a JDK, not a JRE)") + val result = compiler.run( + null, null, null, + "-d", classesDir.absolutePath, + "-cp", System.getProperty("java.class.path"), + sourceFile.absolutePath + ) + assertEquals(0, result, compilationFailureMessage) + + val jarFile = File(fixtureRoot, "$fixtureName-fixture.jar") + JarOutputStream(FileOutputStream(jarFile)).use { jar -> + classesDir.walkTopDown().filter { it.isFile }.forEach { classFile -> + val entryName = classFile.relativeTo(classesDir).path.replace(File.separatorChar, '/') + jar.putNextEntry(JarEntry(entryName)) + jar.write(classFile.readBytes()) + jar.closeEntry() + } + } + return jarFile + } + /** * Compiles a trivial `OpenAPINormalizer` subclass and packages it into a jar file that is * *not* on the Gradle plugin's own runtime/test classpath, simulating a user-supplied * normalizer artifact. */ private fun buildNormalizerFixtureJar(): File { - val fixtureRoot = Files.createTempDirectory("normalizer-fixture").toFile() - fixtureRoots.add(fixtureRoot) - val sourceDir = File(fixtureRoot, "src").apply { mkdirs() } - val classesDir = File(fixtureRoot, "classes").apply { mkdirs() } - - val packageDir = File(sourceDir, "com/example/fixture").apply { mkdirs() } - val sourceFile = File(packageDir, "NoOpNormalizer.java") - sourceFile.writeText( + return buildFixtureJar( + "normalizer", + "NoOpNormalizer.java", """ package com.example.fixture; @@ -86,41 +118,15 @@ class GeneratorClasspathIsolationTest : TestBase() { super.normalize(); } } - """.trimIndent() - ) - - val compiler = ToolProvider.getSystemJavaCompiler() - ?: throw SkipException("No system Java compiler available (test requires a JDK, not a JRE)") - val classpath = System.getProperty("java.class.path") - val result = compiler.run( - null, null, null, - "-d", classesDir.absolutePath, - "-cp", classpath, - sourceFile.absolutePath + """.trimIndent(), + "Failed to compile NORMALIZER_CLASS test fixture" ) - assertEquals(0, result, "Failed to compile NORMALIZER_CLASS test fixture") - - val jarFile = File(fixtureRoot, "normalizer-fixture.jar") - JarOutputStream(FileOutputStream(jarFile)).use { jar -> - classesDir.walkTopDown().filter { it.isFile }.forEach { classFile -> - val entryName = classFile.relativeTo(classesDir).path.replace(File.separatorChar, '/') - jar.putNextEntry(JarEntry(entryName)) - jar.write(classFile.readBytes()) - jar.closeEntry() - } - } - return jarFile } private fun buildGeneratorFixtureJar(): File { - val fixtureRoot = Files.createTempDirectory("generator-fixture").toFile() - fixtureRoots.add(fixtureRoot) - val sourceDir = File(fixtureRoot, "src").apply { mkdirs() } - val classesDir = File(fixtureRoot, "classes").apply { mkdirs() } - - val packageDir = File(sourceDir, "com/example/fixture").apply { mkdirs() } - val sourceFile = File(packageDir, "MarkerCodegen.java") - sourceFile.writeText( + return buildFixtureJar( + "generator", + "MarkerCodegen.java", """ package com.example.fixture; @@ -148,29 +154,9 @@ class GeneratorClasspathIsolationTest : TestBase() { super.processOpts(); } } - """.trimIndent() - ) - - val compiler = ToolProvider.getSystemJavaCompiler() - ?: throw SkipException("No system Java compiler available (test requires a JDK, not a JRE)") - val result = compiler.run( - null, null, null, - "-d", classesDir.absolutePath, - "-cp", System.getProperty("java.class.path"), - sourceFile.absolutePath + """.trimIndent(), + "Failed to compile custom generator test fixture" ) - assertEquals(0, result, "Failed to compile custom generator test fixture") - - val jarFile = File(fixtureRoot, "generator-fixture.jar") - JarOutputStream(FileOutputStream(jarFile)).use { jar -> - classesDir.walkTopDown().filter { it.isFile }.forEach { classFile -> - val entryName = classFile.relativeTo(classesDir).path.replace(File.separatorChar, '/') - jar.putNextEntry(JarEntry(entryName)) - jar.write(classFile.readBytes()) - jar.closeEntry() - } - } - return jarFile } private fun runOpenApiGenerateExpectingSuccess(buildContents: String): org.gradle.testkit.runner.BuildResult = diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConfigLoader.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConfigLoader.java index 9c691b58e7d5..0875786cdefa 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConfigLoader.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConfigLoader.java @@ -18,8 +18,10 @@ package org.openapitools.codegen; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; import java.util.ServiceLoader; +import java.util.Set; public class CodegenConfigLoader { /** @@ -29,11 +31,9 @@ public class CodegenConfigLoader { * @return config class */ public static CodegenConfig forName(String name) { - ServiceLoader loader = ServiceLoader.load(CodegenConfig.class, getConfigClassLoader()); - StringBuilder availableConfigs = new StringBuilder(); - for (CodegenConfig config : loader) { + for (CodegenConfig config : getAll()) { if (config.getName().equals(name)) { return config; } @@ -44,11 +44,8 @@ public static CodegenConfig forName(String name) { // else try to load directly try { return loadConfigClass(name).asSubclass(CodegenConfig.class).getDeclaredConstructor().newInstance(); - } catch (ClassNotFoundException e) { - throw new GeneratorNotFoundException( - "Can't load config class with name '" + name + "'. The class was not found on the generation " - + "runtime classpath. Ensure the class (and its dependencies) is on the classpath used " - + "to launch the generator.\nAvailable:\n" + availableConfigs, e); + } catch (ClassNotFoundException | LinkageError e) { + throw generatorNotFoundException(name, availableConfigs, e); } catch (ReflectiveOperationException | ClassCastException e) { throw new GeneratorNotFoundException( "Can't instantiate config class with name '" + name + "'. The class was found but could not be " @@ -58,10 +55,15 @@ public static CodegenConfig forName(String name) { } public static List getAll() { - ServiceLoader loader = ServiceLoader.load(CodegenConfig.class, getConfigClassLoader()); List output = new ArrayList(); - for (CodegenConfig aLoader : loader) { - output.add(aLoader); + Set configClasses = new HashSet(); + for (ClassLoader classLoader : getConfigClassLoaders()) { + ServiceLoader loader = ServiceLoader.load(CodegenConfig.class, classLoader); + for (CodegenConfig config : loader) { + if (configClasses.add(config.getClass().getName())) { + output.add(config); + } + } } return output; } @@ -71,6 +73,15 @@ private static ClassLoader getConfigClassLoader() { return contextClassLoader != null ? contextClassLoader : CodegenConfig.class.getClassLoader(); } + private static List getConfigClassLoaders() { + ClassLoader primaryClassLoader = getConfigClassLoader(); + ClassLoader definingClassLoader = CodegenConfig.class.getClassLoader(); + if (primaryClassLoader == definingClassLoader) { + return List.of(definingClassLoader); + } + return List.of(primaryClassLoader, definingClassLoader); + } + private static Class loadConfigClass(String className) throws ClassNotFoundException { ClassLoader classLoader = getConfigClassLoader(); try { @@ -82,4 +93,13 @@ private static Class loadConfigClass(String className) throws ClassNotFoundEx throw ignored; } } + + private static GeneratorNotFoundException generatorNotFoundException(String name, + StringBuilder availableConfigs, + Throwable cause) { + return new GeneratorNotFoundException( + "Can't load config class with name '" + name + "'. The class or one of its dependencies could not " + + "be loaded from the generation runtime classpath. Ensure the class (and its dependencies) " + + "are on the classpath used to launch the generator.\nAvailable:\n" + availableConfigs, cause); + } } diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/CodegenConfigLoaderTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/CodegenConfigLoaderTest.java index 8d12646c48c3..92f7ef580c4f 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/CodegenConfigLoaderTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/CodegenConfigLoaderTest.java @@ -83,9 +83,10 @@ public void testConfigClassFallsBackWhenContextClassLoaderCannotResolveClass() t try (URLClassLoader isolatedLoader = new URLClassLoader(new URL[0], null)) { Thread.currentThread().setContextClassLoader(isolatedLoader); - CodegenConfig config = CodegenConfigLoader.forName(DefaultCodegen.class.getName()); + CodegenConfig config = CodegenConfigLoader.forName("java"); - assertEquals(config.getClass(), DefaultCodegen.class); + assertEquals(config.getName(), "java"); + assertTrue(CodegenConfigLoader.getAll().stream().anyMatch(candidate -> "java".equals(candidate.getName()))); } finally { Thread.currentThread().setContextClassLoader(originalTccl); } @@ -137,12 +138,45 @@ public void testFoundConfigClassWithoutPublicNoArgConstructorProducesClearErrorM } } + @Test + public void testConfigClassWithLinkageErrorProducesClasspathGuidance() throws Exception { + Path classesDir = Files.createTempDirectory("codegen-config-linkage-test"); + try { + String className = "org.openapitools.codegen.testfixture.LinkageErrorCodegen"; + compileCodegenFixture(classesDir, className, true, "linkage-error-codegen", + " static { if (System.nanoTime() >= 0) throw new NoClassDefFoundError(\"missing dependency\"); }\n"); + + ClassLoader originalTccl = Thread.currentThread().getContextClassLoader(); + try (URLClassLoader isolatedLoader = new URLClassLoader( + new URL[]{classesDir.toUri().toURL()}, originalTccl)) { + Thread.currentThread().setContextClassLoader(isolatedLoader); + + GeneratorNotFoundException exception = expectThrows(GeneratorNotFoundException.class, + () -> CodegenConfigLoader.forName(className)); + + assertTrue(exception.getMessage().contains(className)); + assertTrue(exception.getMessage().contains("classpath")); + assertTrue(exception.getCause() instanceof NoClassDefFoundError); + } finally { + Thread.currentThread().setContextClassLoader(originalTccl); + } + } finally { + deleteRecursively(classesDir); + } + } + private static void compileCodegenFixture(Path outputDir, String fullyQualifiedClassName) throws Exception { - compileCodegenFixture(outputDir, fullyQualifiedClassName, true, "tccl-only-codegen"); + compileCodegenFixture(outputDir, fullyQualifiedClassName, true, "tccl-only-codegen", ""); } private static void compileCodegenFixture(Path outputDir, String fullyQualifiedClassName, boolean publicNoArgConstructor, String generatorName) throws Exception { + compileCodegenFixture(outputDir, fullyQualifiedClassName, publicNoArgConstructor, generatorName, ""); + } + + private static void compileCodegenFixture(Path outputDir, String fullyQualifiedClassName, + boolean publicNoArgConstructor, String generatorName, + String staticInitializer) throws Exception { int lastDot = fullyQualifiedClassName.lastIndexOf('.'); String packageName = fullyQualifiedClassName.substring(0, lastDot); String simpleName = fullyQualifiedClassName.substring(lastDot + 1); @@ -154,6 +188,7 @@ private static void compileCodegenFixture(Path outputDir, String fullyQualifiedC Files.writeString(sourceFile, "package " + packageName + ";\n" + "public class " + simpleName + " extends org.openapitools.codegen.DefaultCodegen {\n" + " " + (publicNoArgConstructor ? "public" : "private") + " " + simpleName + "() {}\n" + + staticInitializer + " @Override public String getName() { return \"" + generatorName + "\"; }\n" + "}\n"); From ee001be6f4501e0ae4ded15e992c62a3f29277d6 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Thu, 6 Aug 2026 00:47:40 +0200 Subject: [PATCH 12/16] fix: enhance CodegenConfigLoader to provide detailed error handling for class loading issues --- .../codegen/CodegenConfigLoader.java | 42 ++++++++++++++++--- .../codegen/CodegenConfigLoaderTest.java | 30 +++++++++++++ 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConfigLoader.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConfigLoader.java index 0875786cdefa..2a2f416105a0 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConfigLoader.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConfigLoader.java @@ -44,8 +44,14 @@ public static CodegenConfig forName(String name) { // else try to load directly try { return loadConfigClass(name).asSubclass(CodegenConfig.class).getDeclaredConstructor().newInstance(); - } catch (ClassNotFoundException | LinkageError e) { + } catch (ClassNotFoundException | NoClassDefFoundError e) { throw generatorNotFoundException(name, availableConfigs, e); + } catch (UnsupportedClassVersionError e) { + throw generatorIncompatibleException(name, availableConfigs, e); + } catch (ExceptionInInitializerError e) { + throw generatorInitializationException(name, availableConfigs, e); + } catch (LinkageError e) { + throw generatorLinkageException(name, availableConfigs, e); } catch (ReflectiveOperationException | ClassCastException e) { throw new GeneratorNotFoundException( "Can't instantiate config class with name '" + name + "'. The class was found but could not be " @@ -59,11 +65,11 @@ public static List getAll() { Set configClasses = new HashSet(); for (ClassLoader classLoader : getConfigClassLoaders()) { ServiceLoader loader = ServiceLoader.load(CodegenConfig.class, classLoader); - for (CodegenConfig config : loader) { - if (configClasses.add(config.getClass().getName())) { - output.add(config); + loader.stream().forEach(provider -> { + if (configClasses.add(provider.type().getName())) { + output.add(provider.get()); } - } + }); } return output; } @@ -102,4 +108,30 @@ private static GeneratorNotFoundException generatorNotFoundException(String name + "be loaded from the generation runtime classpath. Ensure the class (and its dependencies) " + "are on the classpath used to launch the generator.\nAvailable:\n" + availableConfigs, cause); } + + private static GeneratorNotFoundException generatorIncompatibleException(String name, + StringBuilder availableConfigs, + Throwable cause) { + return new GeneratorNotFoundException( + "Can't load config class with name '" + name + "'. The class or one of its dependencies was compiled " + + "for an incompatible Java version. Use a generator compiled for the Java version running " + + "OpenAPI Generator.\nAvailable:\n" + availableConfigs, cause); + } + + private static GeneratorNotFoundException generatorInitializationException(String name, + StringBuilder availableConfigs, + Throwable cause) { + return new GeneratorNotFoundException( + "Can't load config class with name '" + name + "'. The class was found but its static initializer " + + "failed; inspect the underlying exception for the cause.\nAvailable:\n" + availableConfigs, cause); + } + + private static GeneratorNotFoundException generatorLinkageException(String name, + StringBuilder availableConfigs, + Throwable cause) { + return new GeneratorNotFoundException( + "Can't load config class with name '" + name + "'. The class was found but could not be linked " + + "(" + cause.getClass().getSimpleName() + "); inspect the underlying error for the cause.\nAvailable:\n" + + availableConfigs, cause); + } } diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/CodegenConfigLoaderTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/CodegenConfigLoaderTest.java index 92f7ef580c4f..3ad52dd44246 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/CodegenConfigLoaderTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/CodegenConfigLoaderTest.java @@ -84,8 +84,10 @@ public void testConfigClassFallsBackWhenContextClassLoaderCannotResolveClass() t Thread.currentThread().setContextClassLoader(isolatedLoader); CodegenConfig config = CodegenConfigLoader.forName("java"); + CodegenConfig configByClassName = CodegenConfigLoader.forName(DefaultCodegen.class.getName()); assertEquals(config.getName(), "java"); + assertEquals(configByClassName.getClass(), DefaultCodegen.class); assertTrue(CodegenConfigLoader.getAll().stream().anyMatch(candidate -> "java".equals(candidate.getName()))); } finally { Thread.currentThread().setContextClassLoader(originalTccl); @@ -165,6 +167,34 @@ public void testConfigClassWithLinkageErrorProducesClasspathGuidance() throws Ex } } + @Test + public void testConfigClassWithFailingStaticInitializerProducesPreciseErrorMessage() throws Exception { + Path classesDir = Files.createTempDirectory("codegen-config-initializer-test"); + try { + String className = "org.openapitools.codegen.testfixture.InitializerErrorCodegen"; + compileCodegenFixture(classesDir, className, true, "initializer-error-codegen", + " static { if (System.nanoTime() >= 0) throw new IllegalStateException(\"fixture failure\"); }\n"); + + ClassLoader originalTccl = Thread.currentThread().getContextClassLoader(); + try (URLClassLoader isolatedLoader = new URLClassLoader( + new URL[]{classesDir.toUri().toURL()}, originalTccl)) { + Thread.currentThread().setContextClassLoader(isolatedLoader); + + GeneratorNotFoundException exception = expectThrows(GeneratorNotFoundException.class, + () -> CodegenConfigLoader.forName(className)); + + assertTrue(exception.getMessage().contains(className)); + assertTrue(exception.getMessage().contains("static initializer failed")); + assertFalse(exception.getMessage().contains("classpath")); + assertTrue(exception.getCause() instanceof ExceptionInInitializerError); + } finally { + Thread.currentThread().setContextClassLoader(originalTccl); + } + } finally { + deleteRecursively(classesDir); + } + } + private static void compileCodegenFixture(Path outputDir, String fullyQualifiedClassName) throws Exception { compileCodegenFixture(outputDir, fullyQualifiedClassName, true, "tccl-only-codegen", ""); } From c20d2d501ca232548ab7093abdf9dd30c70edc72 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Thu, 6 Aug 2026 01:00:37 +0200 Subject: [PATCH 13/16] fix: enhance CodegenConfigLoader to improve error handling for class loading exceptions --- .../java/org/openapitools/codegen/CodegenConfigLoader.java | 7 ++++++- .../org/openapitools/codegen/CodegenConfigLoaderTest.java | 7 +++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConfigLoader.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConfigLoader.java index 2a2f416105a0..b94274148a2e 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConfigLoader.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConfigLoader.java @@ -44,7 +44,12 @@ public static CodegenConfig forName(String name) { // else try to load directly try { return loadConfigClass(name).asSubclass(CodegenConfig.class).getDeclaredConstructor().newInstance(); - } catch (ClassNotFoundException | NoClassDefFoundError e) { + } catch (ClassNotFoundException e) { + throw generatorNotFoundException(name, availableConfigs, e); + } catch (NoClassDefFoundError e) { + if (e.getCause() instanceof ExceptionInInitializerError) { + throw generatorInitializationException(name, availableConfigs, e); + } throw generatorNotFoundException(name, availableConfigs, e); } catch (UnsupportedClassVersionError e) { throw generatorIncompatibleException(name, availableConfigs, e); diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/CodegenConfigLoaderTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/CodegenConfigLoaderTest.java index 3ad52dd44246..3150a7b952c5 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/CodegenConfigLoaderTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/CodegenConfigLoaderTest.java @@ -187,6 +187,13 @@ public void testConfigClassWithFailingStaticInitializerProducesPreciseErrorMessa assertTrue(exception.getMessage().contains("static initializer failed")); assertFalse(exception.getMessage().contains("classpath")); assertTrue(exception.getCause() instanceof ExceptionInInitializerError); + + GeneratorNotFoundException retryException = expectThrows(GeneratorNotFoundException.class, + () -> CodegenConfigLoader.forName(className)); + + assertTrue(retryException.getMessage().contains("static initializer failed")); + assertFalse(retryException.getMessage().contains("classpath")); + assertTrue(retryException.getCause() instanceof NoClassDefFoundError); } finally { Thread.currentThread().setContextClassLoader(originalTccl); } From 820f0ff2403520fd903c8111aeeb83aaeb579a39 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Thu, 6 Aug 2026 08:19:01 +0200 Subject: [PATCH 14/16] fix test --- .../java/org/openapitools/codegen/CodegenConfigLoaderTest.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/CodegenConfigLoaderTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/CodegenConfigLoaderTest.java index 3150a7b952c5..2b132a333c2a 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/CodegenConfigLoaderTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/CodegenConfigLoaderTest.java @@ -191,8 +191,7 @@ public void testConfigClassWithFailingStaticInitializerProducesPreciseErrorMessa GeneratorNotFoundException retryException = expectThrows(GeneratorNotFoundException.class, () -> CodegenConfigLoader.forName(className)); - assertTrue(retryException.getMessage().contains("static initializer failed")); - assertFalse(retryException.getMessage().contains("classpath")); + assertTrue(retryException.getMessage().contains(className)); assertTrue(retryException.getCause() instanceof NoClassDefFoundError); } finally { Thread.currentThread().setContextClassLoader(originalTccl); From 688cf4b29bf3daad99efc05e5bc03901c88e5b2e Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Thu, 6 Aug 2026 08:52:16 +0200 Subject: [PATCH 15/16] fix: enhance CodegenConfigLoader to improve handling of initialization failures during class loading --- .../codegen/CodegenConfigLoader.java | 35 +++++++++++++++++-- .../codegen/CodegenConfigLoaderTest.java | 2 ++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConfigLoader.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConfigLoader.java index b94274148a2e..5a2343de8028 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConfigLoader.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConfigLoader.java @@ -18,12 +18,18 @@ package org.openapitools.codegen; import java.util.ArrayList; +import java.util.Collections; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.ServiceLoader; import java.util.Set; +import java.util.WeakHashMap; public class CodegenConfigLoader { + private static final Map> INITIALIZATION_FAILURES = + Collections.synchronizedMap(new WeakHashMap<>()); + /** * Tries to load config class with SPI first, then with class name directly from classpath * @@ -47,7 +53,7 @@ public static CodegenConfig forName(String name) { } catch (ClassNotFoundException e) { throw generatorNotFoundException(name, availableConfigs, e); } catch (NoClassDefFoundError e) { - if (e.getCause() instanceof ExceptionInInitializerError) { + if (e.getCause() instanceof ExceptionInInitializerError || hasInitializationFailed(name)) { throw generatorInitializationException(name, availableConfigs, e); } throw generatorNotFoundException(name, availableConfigs, e); @@ -96,15 +102,38 @@ private static List getConfigClassLoaders() { private static Class loadConfigClass(String className) throws ClassNotFoundException { ClassLoader classLoader = getConfigClassLoader(); try { - return Class.forName(className, true, classLoader); + return loadConfigClass(className, classLoader); } catch (ClassNotFoundException ignored) { if (classLoader != CodegenConfig.class.getClassLoader()) { - return Class.forName(className, true, CodegenConfig.class.getClassLoader()); + return loadConfigClass(className, CodegenConfig.class.getClassLoader()); } throw ignored; } } + private static Class loadConfigClass(String className, ClassLoader classLoader) throws ClassNotFoundException { + try { + return Class.forName(className, true, classLoader); + } catch (ExceptionInInitializerError e) { + rememberInitializationFailure(className, classLoader); + throw e; + } + } + + private static void rememberInitializationFailure(String name, ClassLoader classLoader) { + synchronized (INITIALIZATION_FAILURES) { + INITIALIZATION_FAILURES.computeIfAbsent(classLoader, ignored -> new HashSet<>()).add(name); + } + } + + private static boolean hasInitializationFailed(String name) { + synchronized (INITIALIZATION_FAILURES) { + return getConfigClassLoaders().stream() + .map(INITIALIZATION_FAILURES::get) + .anyMatch(failures -> failures != null && failures.contains(name)); + } + } + private static GeneratorNotFoundException generatorNotFoundException(String name, StringBuilder availableConfigs, Throwable cause) { diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/CodegenConfigLoaderTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/CodegenConfigLoaderTest.java index 2b132a333c2a..c0ecc49e0f29 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/CodegenConfigLoaderTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/CodegenConfigLoaderTest.java @@ -192,6 +192,8 @@ public void testConfigClassWithFailingStaticInitializerProducesPreciseErrorMessa () -> CodegenConfigLoader.forName(className)); assertTrue(retryException.getMessage().contains(className)); + assertTrue(retryException.getMessage().contains("static initializer failed")); + assertFalse(retryException.getMessage().contains("classpath")); assertTrue(retryException.getCause() instanceof NoClassDefFoundError); } finally { Thread.currentThread().setContextClassLoader(originalTccl); From c76fd40bafd822c03f75746566a4befc57b01931 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Thu, 6 Aug 2026 12:43:03 +0200 Subject: [PATCH 16/16] implement CR suggestion --- .../openapitools/codegen/CodegenConfigLoader.java | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConfigLoader.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConfigLoader.java index 5a2343de8028..88802d6d7b78 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConfigLoader.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConfigLoader.java @@ -29,6 +29,7 @@ public class CodegenConfigLoader { private static final Map> INITIALIZATION_FAILURES = Collections.synchronizedMap(new WeakHashMap<>()); + private static final ThreadLocal LOADING_CLASS_LOADER = new ThreadLocal<>(); /** * Tries to load config class with SPI first, then with class name directly from classpath @@ -68,6 +69,8 @@ public static CodegenConfig forName(String name) { "Can't instantiate config class with name '" + name + "'. The class was found but could not be " + "constructed; it must implement CodegenConfig, declare a public no-argument constructor, " + "and that constructor must not throw.\nAvailable:\n" + availableConfigs, e); + } finally { + LOADING_CLASS_LOADER.remove(); } } @@ -112,6 +115,7 @@ private static Class loadConfigClass(String className) throws ClassNotFoundEx } private static Class loadConfigClass(String className, ClassLoader classLoader) throws ClassNotFoundException { + LOADING_CLASS_LOADER.set(classLoader); try { return Class.forName(className, true, classLoader); } catch (ExceptionInInitializerError e) { @@ -127,10 +131,13 @@ private static void rememberInitializationFailure(String name, ClassLoader class } private static boolean hasInitializationFailed(String name) { + ClassLoader classLoader = LOADING_CLASS_LOADER.get(); + if (classLoader == null) { + return false; + } synchronized (INITIALIZATION_FAILURES) { - return getConfigClassLoaders().stream() - .map(INITIALIZATION_FAILURES::get) - .anyMatch(failures -> failures != null && failures.contains(name)); + Set failures = INITIALIZATION_FAILURES.get(classLoader); + return failures != null && failures.contains(name); } }