diff --git a/docs/customization.md b/docs/customization.md index 66f5aa654d8f..e8ef4ca6b852 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -646,6 +646,18 @@ 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. + +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 4ebb4ff66535..2e8761cc871c 100644 --- a/modules/openapi-generator-gradle-plugin/README.adoc +++ b/modules/openapi-generator-gradle-plugin/README.adoc @@ -480,8 +480,56 @@ 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`) 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 +files/directories: + +[source,groovy] +---- +openApiGenerate { + generatorClasspath.from(files("libs/my-normalizer.jar", "libs/my-custom-generator.jar")) +} +---- |=== +[NOTE] +==== +The plugin creates an `openApiGeneratorExtra` dependency configuration (resolvable, not published) whose entries +are automatically forwarded into `generatorClasspath` for both `workerIsolation` modes. Use it to declare a +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") // 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` or +custom generator selected by name/FQCN 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..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() @@ -536,6 +541,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..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 @@ -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 */ @@ -693,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 @@ -1148,6 +1164,7 @@ abstract class GenerateTask : DefaultTask() { ) } workerExecutor.processIsolation { + classpath.from(generatorClasspath) maxWorkerHeapSize.orNull?.let { forkOptions.maxHeapSize = it } } } @@ -1160,7 +1177,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..cf98118c7ee9 --- /dev/null +++ b/modules/openapi-generator-gradle-plugin/src/test/kotlin/GeneratorClasspathIsolationTest.kt @@ -0,0 +1,431 @@ +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 +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" + private const val GENERATOR_CLASS_NAME = "com.example.fixture.MarkerCodegen" + } + + private val fixtureRoots = mutableListOf() + + @AfterMethod + fun cleanUpFixtureRoots() { + fixtureRoots.forEach { it.deleteRecursively() } + 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 { + return buildFixtureJar( + "normalizer", + "NoOpNormalizer.java", + """ + 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(), + "Failed to compile NORMALIZER_CLASS test fixture" + ) + } + + private fun buildGeneratorFixtureJar(): File { + return buildFixtureJar( + "generator", + "MarkerCodegen.java", + """ + 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(), + "Failed to compile custom generator test fixture" + ) + } + + private data class Fixture(val jar: File, val marker: File) + + private fun normalizerFixture(): Fixture { + writeSpec() + return Fixture(buildNormalizerFixtureJar(), File(temp, "normalizer-ran.marker")) + } + + private fun generatorFixture(): Fixture { + writeSpec() + return Fixture(buildGeneratorFixtureJar(), File(temp, "generator-ran.marker")) + } + + private fun groovyPath(file: File) = file.absolutePath.replace("\\", "\\\\") + + private fun buildScript(taskConfiguration: String, extraClasspath: File? = null) = buildString { + appendLine("plugins { id 'org.openapi.generator' }") + extraClasspath?.let { + appendLine() + appendLine("dependencies {") + appendLine(" openApiGeneratorExtra(files(\"${groovyPath(it)}\"))") + appendLine("}") + } + appendLine("openApiGenerate {") + appendLine(taskConfiguration.prependIndent(" ")) + appendLine("}") + } + + private fun withWorkerConfiguration( + taskConfiguration: String, + workerIsolation: String?, + generatorClasspath: File? + ) = listOfNotNull( + taskConfiguration, + workerIsolation?.let { "workerIsolation = \"$it\"" }, + generatorClasspath?.let { """generatorClasspath.from(files("${groovyPath(it)}"))""" } + ).joinToString("\n") + + private fun normalizerConfiguration( + marker: File, + workerIsolation: String? = null, + generatorClasspath: File? = null + ) = withWorkerConfiguration( + """ + generatorName = "kotlin" + inputSpec = file("spec.yaml").absolutePath + outputDir = file("build/kotlin").absolutePath + openapiNormalizer = ["NORMALIZER_CLASS": "$NORMALIZER_CLASS_NAME", "MARKER_FILE": "${groovyPath(marker)}"] + """.trimIndent(), + workerIsolation, + generatorClasspath + ) + + private fun generatorConfiguration( + marker: File, + workerIsolation: String? = null, + generatorClasspath: File? = null + ) = withWorkerConfiguration( + """ + generatorName = "$GENERATOR_CLASS_NAME" + inputSpec = file("spec.yaml").absolutePath + outputDir = file("build/generator").absolutePath + additionalProperties = [markerFile: "${groovyPath(marker)}"] + """.trimIndent(), + workerIsolation, + generatorClasspath + ) + + private fun createRunner(buildContents: String): GradleRunner = + GradleRunner.create() + .withProjectDir(temp) + .withArguments("openApiGenerate", "--stacktrace") + .withPluginClasspath() + .also { File(temp, "build.gradle").writeText(buildContents) } + + private fun runOpenApiGenerateExpectingSuccess(taskConfiguration: String, extraClasspath: File? = null) = + createRunner(buildScript(taskConfiguration, extraClasspath)).build() + + private fun runOpenApiGenerateExpectingFailure(taskConfiguration: String) = + createRunner(buildScript(taskConfiguration)).buildAndFail() + + private fun writeSpec(): 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`() { + writeSpec() + 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 + // output instead of the task outcome. + val result = runOpenApiGenerateExpectingSuccess(normalizerConfiguration(marker)) + + assertTrue( + result.output.contains("ClassNotFoundException"), + "Expected a ClassNotFoundException to be reported for the unresolvable NORMALIZER_CLASS, got:\n${result.output}" + ) + assertTrue( + 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}" + ) + // 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, 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 + // 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}" + ) + // 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()) + } + + @Test + fun `custom generator without generatorClasspath fails with a clear error`() { + writeSpec() + val marker = File(temp, "generator-ran.marker") + + val result = runOpenApiGenerateExpectingFailure(generatorConfiguration(marker)) + + 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`() { + val fixture = generatorFixture() + + val result = runOpenApiGenerateExpectingSuccess( + generatorConfiguration(fixture.marker, "process"), + fixture.jar + ) + + assertGeneratorLoadedSuccessfully(result, fixture.marker) + } + + @Test + fun `custom generator loads via openApiGeneratorExtra configuration under classloader isolation`() { + val fixture = generatorFixture() + + val result = runOpenApiGenerateExpectingSuccess( + generatorConfiguration(fixture.marker, "classloader"), + fixture.jar + ) + + assertGeneratorLoadedSuccessfully(result, fixture.marker) + } + + @Test + fun `custom generator loads via generatorClasspath property under process isolation`() { + val fixture = generatorFixture() + + val result = runOpenApiGenerateExpectingSuccess( + generatorConfiguration(fixture.marker, "process", fixture.jar) + ) + + assertGeneratorLoadedSuccessfully(result, fixture.marker) + } + + @Test + fun `custom generator loads via generatorClasspath property under classloader isolation`() { + val fixture = generatorFixture() + + val result = runOpenApiGenerateExpectingSuccess( + generatorConfiguration(fixture.marker, "classloader", fixture.jar) + ) + + assertGeneratorLoadedSuccessfully(result, fixture.marker) + } + + // ------------------------------------------------------------------------- + // openApiGeneratorExtra configuration - process isolation + // ------------------------------------------------------------------------- + + @Test + fun `custom NORMALIZER_CLASS loads via openApiGeneratorExtra configuration under process isolation`() { + val fixture = normalizerFixture() + + val result = runOpenApiGenerateExpectingSuccess( + normalizerConfiguration(fixture.marker, "process"), + fixture.jar + ) + + assertNormalizerLoadedSuccessfully(result, fixture.marker) + } + + // ------------------------------------------------------------------------- + // openApiGeneratorExtra configuration - classloader isolation + // ------------------------------------------------------------------------- + + @Test + fun `custom NORMALIZER_CLASS loads via openApiGeneratorExtra configuration under classloader isolation`() { + val fixture = normalizerFixture() + + val result = runOpenApiGenerateExpectingSuccess( + normalizerConfiguration(fixture.marker, "classloader"), + fixture.jar + ) + + assertNormalizerLoadedSuccessfully(result, fixture.marker) + } + + // ------------------------------------------------------------------------- + // generatorClasspath extension property - low-level escape hatch, process isolation + // ------------------------------------------------------------------------- + + @Test + fun `custom NORMALIZER_CLASS loads via generatorClasspath property under process isolation`() { + val fixture = normalizerFixture() + + val result = runOpenApiGenerateExpectingSuccess( + normalizerConfiguration(fixture.marker, "process", fixture.jar) + ) + + assertNormalizerLoadedSuccessfully(result, fixture.marker) + } + + // ------------------------------------------------------------------------- + // generatorClasspath extension property - low-level escape hatch, classloader isolation + // ------------------------------------------------------------------------- + + @Test + fun `custom NORMALIZER_CLASS loads via generatorClasspath property under classloader isolation`() { + val fixture = normalizerFixture() + + val result = runOpenApiGenerateExpectingSuccess( + normalizerConfiguration(fixture.marker, "classloader", fixture.jar) + ) + + assertNormalizerLoadedSuccessfully(result, fixture.marker) + } +} 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..90c5c690937e 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,10 +18,24 @@ package org.openapitools.codegen; import java.util.ArrayList; +import java.util.HashSet; +import java.util.Iterator; import java.util.List; +import java.util.Map; import java.util.ServiceLoader; +import java.util.ServiceConfigurationError; +import java.util.Set; +import java.util.WeakHashMap; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; public class CodegenConfigLoader { + private static final Logger LOGGER = LoggerFactory.getLogger(CodegenConfigLoader.class); + // Guarded entirely by explicit synchronization on the map below, so a plain WeakHashMap suffices. + private static final Map> INITIALIZATION_FAILURES = 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 * @@ -29,11 +43,9 @@ public class CodegenConfigLoader { * @return config class */ public static CodegenConfig forName(String name) { - ServiceLoader loader = ServiceLoader.load(CodegenConfig.class, CodegenConfig.class.getClassLoader()); - StringBuilder availableConfigs = new StringBuilder(); - for (CodegenConfig config : loader) { + for (CodegenConfig config : getAll()) { if (config.getName().equals(name)) { return config; } @@ -43,18 +55,153 @@ 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 generatorNotFoundException(name, availableConfigs, e); + } catch (NoClassDefFoundError e) { + if (e.getCause() instanceof ExceptionInInitializerError || hasInitializationFailed(name)) { + throw generatorInitializationException(name, availableConfigs, 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 " + + "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(); } } public static List getAll() { - ServiceLoader loader = ServiceLoader.load(CodegenConfig.class, CodegenConfig.class.getClassLoader()); 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); + Iterator> providers = loader.stream().iterator(); + while (true) { + ServiceLoader.Provider provider; + // Per-provider failures (missing/invalid class, LinkageError) surface while advancing + // the iterator; stop enumerating this classloader on any such error to guarantee + // termination (a resource-location IOException would otherwise never advance and spin + // forever). Providers already yielded are kept, and the fallback classloader plus the + // direct forName() load below still cover anything not enumerated here. + try { + if (!providers.hasNext()) { + break; + } + provider = providers.next(); + } catch (ServiceConfigurationError | LinkageError e) { + LOGGER.warn("Unable to enumerate codegen config provider from {}", classLoader, e); + break; + } + // Cursor has advanced past this provider, so loading/instantiation failures are safe to skip. + try { + String configClassName = provider.type().getName(); + if (!configClasses.contains(configClassName)) { + CodegenConfig config = provider.get(); + configClasses.add(configClassName); + output.add(config); + } + } catch (ServiceConfigurationError | LinkageError e) { + LOGGER.warn("Unable to load codegen config provider from {}", classLoader, e); + } + } } return output; } + + private static ClassLoader getConfigClassLoader() { + ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader(); + 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 { + return loadConfigClass(className, classLoader); + } catch (ClassNotFoundException ignored) { + if (classLoader != CodegenConfig.class.getClassLoader()) { + return loadConfigClass(className, CodegenConfig.class.getClassLoader()); + } + throw ignored; + } + } + + 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) { + 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) { + ClassLoader classLoader = LOADING_CLASS_LOADER.get(); + if (classLoader == null) { + return false; + } + synchronized (INITIALIZATION_FAILURES) { + Set failures = INITIALIZATION_FAILURES.get(classLoader); + return failures != null && failures.contains(name); + } + } + + 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); + } + + 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/main/java/org/openapitools/codegen/OpenAPINormalizer.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java index 461311648c0c..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 @@ -181,18 +181,55 @@ 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 = Class.forName(inputRules.get(NORMALIZER_CLASS)); - Constructor constructor = clazz.getConstructor(OpenAPI.class, Map.class); + 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 " + + "current thread's context classloader or by the classloader that loaded " + + "openapi-generator itself). Ensure the class (and its dependencies) is on the " + + "classpath used to launch the generator.", e); + } + try { + 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 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); } } + /** + * 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/CodegenConfigLoaderTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/CodegenConfigLoaderTest.java new file mode 100644 index 000000000000..1a782680f475 --- /dev/null +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/CodegenConfigLoaderTest.java @@ -0,0 +1,355 @@ +/* + * 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.List; +import java.util.stream.Stream; + +import static org.testng.Assert.*; + +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(); + try (URLClassLoader isolatedLoader = new URLClassLoader( + new URL[]{classesDir.toUri().toURL()}, originalTccl)) { + 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); + } + } 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(); + try (URLClassLoader isolatedLoader = new URLClassLoader(new URL[0], null)) { + 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); + } + } + + @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:")); + } + + @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, "private-constructor-codegen"); + + 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); + } + } + + @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); + } + } + + @Test + public void testUnrelatedBrokenSpiProviderDoesNotPreventDirectConfigLoading() throws Exception { + Path classesDir = Files.createTempDirectory("codegen-config-broken-spi-test"); + try { + String className = "org.openapitools.codegen.testfixture.BrokenSpiCodegen"; + compileCodegenFixture(classesDir, className, true, "broken-spi-codegen", + " static { if (System.nanoTime() >= 0) throw new IllegalStateException(\"fixture failure\"); }\n"); + Path serviceFile = classesDir.resolve("META-INF/services/" + CodegenConfig.class.getName()); + Files.createDirectories(serviceFile.getParent()); + Files.writeString(serviceFile, className); + + ClassLoader originalTccl = Thread.currentThread().getContextClassLoader(); + try (URLClassLoader isolatedLoader = new URLClassLoader( + new URL[]{classesDir.toUri().toURL()}, originalTccl)) { + Thread.currentThread().setContextClassLoader(isolatedLoader); + + CodegenConfig config = CodegenConfigLoader.forName(DefaultCodegen.class.getName()); + + assertEquals(config.getClass(), DefaultCodegen.class); + } finally { + Thread.currentThread().setContextClassLoader(originalTccl); + } + } finally { + deleteRecursively(classesDir); + } + } + + @Test + public void testMalformedSpiEntryDoesNotPreventDirectConfigLoading() throws Exception { + Path classesDir = Files.createTempDirectory("codegen-config-malformed-spi-test"); + try { + Path serviceFile = classesDir.resolve("META-INF/services/" + CodegenConfig.class.getName()); + Files.createDirectories(serviceFile.getParent()); + Files.writeString(serviceFile, "org.openapitools.codegen.testfixture.DoesNotExist"); + + ClassLoader originalTccl = Thread.currentThread().getContextClassLoader(); + try (URLClassLoader isolatedLoader = new URLClassLoader( + new URL[]{classesDir.toUri().toURL()}, originalTccl)) { + Thread.currentThread().setContextClassLoader(isolatedLoader); + + CodegenConfig config = CodegenConfigLoader.forName(DefaultCodegen.class.getName()); + + assertEquals(config.getClass(), DefaultCodegen.class); + } finally { + Thread.currentThread().setContextClassLoader(originalTccl); + } + } finally { + deleteRecursively(classesDir); + } + } + + @Test + public void testEnumerationStopsAtMalformedEntryAndSkipsLaterEntriesInSameServiceFile() throws Exception { + // Documents the deliberate behavior of getAll(): a failure while *advancing* the ServiceLoader + // iterator (here a non-existent provider class named on the first line of a service file) stops + // enumeration of that classloader, so a valid provider listed *after* the malformed entry in the + // same file is not discovered. This keeps enumeration guaranteed-terminating without depending on + // any JDK-internal error message. Overall discovery still degrades gracefully: providers from the + // defining classloader (the real generators) are still returned. + Path classesDir = Files.createTempDirectory("codegen-config-break-on-advancement-test"); + try { + String afterClassName = "org.openapitools.codegen.testfixture.AfterMalformedEntryCodegen"; + String afterGeneratorName = "after-malformed-entry-codegen"; + compileCodegenFixture(classesDir, afterClassName, true, afterGeneratorName); + + Path serviceFile = classesDir.resolve("META-INF/services/" + CodegenConfig.class.getName()); + Files.createDirectories(serviceFile.getParent()); + // First line is unresolvable (advancement error -> break); the valid second line is never reached. + Files.writeString(serviceFile, + "org.openapitools.codegen.testfixture.DoesNotExist\n" + afterClassName + "\n"); + + ClassLoader originalTccl = Thread.currentThread().getContextClassLoader(); + try (URLClassLoader isolatedLoader = new URLClassLoader( + new URL[]{classesDir.toUri().toURL()}, originalTccl)) { + Thread.currentThread().setContextClassLoader(isolatedLoader); + + List all = CodegenConfigLoader.getAll(); + + // The valid provider after the malformed entry is skipped because enumeration broke. + assertFalse(all.stream().anyMatch(candidate -> afterGeneratorName.equals(candidate.getName())), + "Provider listed after a malformed entry in the same service file must not be discovered"); + // Overall discovery still returns the real generators from the defining classloader. + assertTrue(all.stream().anyMatch(candidate -> "java".equals(candidate.getName())), + "Real generators from the defining classloader must still be discovered"); + } finally { + Thread.currentThread().setContextClassLoader(originalTccl); + } + } finally { + deleteRecursively(classesDir); + } + } + + @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); + + GeneratorNotFoundException retryException = expectThrows(GeneratorNotFoundException.class, + () -> 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); + } + } finally { + deleteRecursively(classesDir); + } + } + + private static void compileCodegenFixture(Path outputDir, String fullyQualifiedClassName) throws Exception { + 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); + 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" + + " " + (publicNoArgConstructor ? "public" : "private") + " " + simpleName + "() {}\n" + + staticInitializer + + " @Override public String getName() { return \"" + generatorName + "\"; }\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 + } + }); + } + } +} 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..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 @@ -26,8 +26,17 @@ 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.io.IOException; 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 java.util.stream.Stream; import static org.openapitools.codegen.CodegenConstants.X_ENUM_DESCRIPTIONS; import static org.testng.Assert.*; @@ -1735,6 +1744,158 @@ 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"); + try { + 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 { + deleteRecursively(classesDir); + } + } + + @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 + 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" + ); + RuntimeException e = expectThrows(RuntimeException.class, + () -> OpenAPINormalizer.createNormalizer(openAPI, inputRules)); + assertTrue(e.getMessage().contains("org.openapitools.codegen.DoesNotExistNormalizer")); + assertTrue(e.getMessage().contains("classpath")); + } + + /** + * 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"); + 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(); + 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(), + "-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 + } + }); + } + } + @Test public void testRemoveXInternalFromInlineProperties() { diff --git a/samples/client/petstore/java/resttemplate-springBoot4-jackson3/pom.xml b/samples/client/petstore/java/resttemplate-springBoot4-jackson3/pom.xml index b5c5141bdd3d..859141904ec9 100644 --- a/samples/client/petstore/java/resttemplate-springBoot4-jackson3/pom.xml +++ b/samples/client/petstore/java/resttemplate-springBoot4-jackson3/pom.xml @@ -269,7 +269,7 @@ UTF-8 - 7.0.8 + 7.0.5 3.1.5 3.0.0