feat: honor thread context classloader when loading custom normalizers and generators

Merged
PI
3mo ago
+1245-14

A custom NORMALIZER_CLASS normalizer (and, more generally, any custom class referenced by name in generator options, such as a custom generator selected by generatorName/FQCN) could fail to load, especially under the Gradle plugin's process worker isolation:

  1. OpenAPINormalizer.createNormalizer used bare Class.forName(String), and CodegenConfigLoader used ServiceLoader.load(..., CodegenConfig.class.getClassLoader()) / bare Class.forName(String), all of which resolve against the classloader that loaded openapi-generator core, not any user-supplied classpath.
  2. The Gradle plugin never called classpath.from(...) in either isolation branch. Under process isolation this is fatal: the forked JVM has zero visibility into anything not explicitly forwarded. Under classloader isolation it was merely fragile: since the worker runs in the same JVM, a custom normalizer could work by accident if it happened to already be reachable through the existing classloader hierarchy (e.g. stuck on the buildscript classpath), but this was never a supported or documented mechanism.

Changes

Core (OpenAPINormalizer.java)

  • createNormalizer now resolves the class via the current thread's context classloader (TCCL) first (which Gradle's Worker API sets to include user classpath), falling back to the original defining classloader for backward compatibility.
  • Failure messages are split by cause: ClassNotFoundException gets classpath guidance, other reflective failures (bad constructor, instantiation errors) get a distinct, accurate message.

Core (CodegenConfigLoader.java)

  • Generator resolution mirrors the normalizer: both SPI discovery (getAll) and direct Class.forName (forName) now consult the TCCL first, falling back to the defining classloader, so custom generators on the worker classpath are found while existing generators keep loading unchanged.
  • getAll() discovers providers across both classloaders, dedupes by class name, and tolerates a broken/unrelated SPI provider (logged and skipped rather than aborting discovery).
  • forName failure messages are split by cause: not found on classpath, static-initializer failure, incompatible Java version, other linkage errors, and "found but not a valid CodegenConfig / missing public no-arg constructor".

Gradle plugin

  • Added generatorClasspath (file collection) and an openApiGeneratorExtra resolvable configuration, both forwarded to the worker in processIsolation and classLoaderIsolation. Users can now supply a custom normalizer or custom generator jar via normal Gradle dependencies or file collections.
  • Documented the classpath requirement in the openapiNormalizer KDoc, README, and customization docs.

Tests

  • Core: added unit tests covering TCCL resolution, TCCL-null and TCCL-miss fallback, and the clearer error message, for both the normalizer (OpenAPINormalizerTest) and the generator loader (CodegenConfigLoaderTest; the latter also covers the not-a-CodegenConfig, missing-constructor, linkage-error, failing-static-initializer, and broken/malformed-SPI cases).
  • Gradle plugin: added GeneratorClasspathIsolationTest covering both classpath mechanisms under both isolation modes, plus a negative control. Positive tests assert a marker file written by the fixture normalizer's normalize() to directly prove it ran, not just that the build succeeded.

Compatibility

Fully backward compatible. No NORMALIZER_CLASS set, or normalizer already visible on the default classpath: unchanged behavior. New properties default to empty/unset. One intentional behavior change: a broken third-party CodegenConfig SPI provider that previously aborted generator discovery is now logged at WARN and skipped.

PR checklist

  • Read the contribution guidelines.
  • Run the following to build the project and update samples:
    ./mvnw clean package || exit
    ./bin/generate-samples.sh ./bin/configs/*.yaml || exit
    ./bin/utils/export_docs_generators.sh || exit
    
    (For Windows users, please run the script in WSL)
    Commit all changed files.
    This is important, as CI jobs will verify all generator outputs of your HEAD commit as it would merge with master.
    These must match the expectations made by your contribution.
    You may regenerate an individual generator by passing the relevant config(s) as an argument to the script, for example ./bin/generate-samples.sh bin/configs/java*.
    IMPORTANT: Do NOT purge/delete any folders/files (e.g. tests) when regenerating the samples as manually written tests may be removed.
  • If your PR is targeting a particular programming language, @mention the technical committee members, so they are more likely to review the pull request.

Summary by cubic

Honor the thread context classloader when loading custom normalizers and generators, and forward user classpaths in the Gradle plugin for both worker isolation modes. Adds clear, tool-agnostic errors, resilient SPI discovery, updated docs, and tests that prove a normalizer and a generator actually run.

  • New Features

    • Core: OpenAPINormalizer prefers the thread context classloader with fallback; splits classpath/not-found vs constructor errors. CodegenConfigLoader prefers the context classloader; getAll() merges/dedupes across classloaders and skips broken/malformed SPI providers and resource I/O errors with warnings; forName() returns precise errors for classpath/not-found (incl. NoClassDefFoundError after a failing initializer), incompatible Java version, static initializer failures, linkage errors, and bad constructors/implementations. Tests cover TCCL resolution, fallbacks, SPI edge cases, and error messaging for both normalizers and generators.
    • Gradle plugin: adds openApiGeneratorExtra and a generatorClasspath property, both forwarded to the worker classpath for workerIsolation process and classloader. README/KDoc/docs explain classpath requirements for NORMALIZER_CLASS and custom generators (by name/FQCN). Tests cover both isolation modes and both classpath mechanisms, generator by FQCN, negative controls, and execution proven via marker files.
  • Migration

    • When using a custom NORMALIZER_CLASS or custom generator (by name/FQCN) with the Gradle plugin, add it to openApiGeneratorExtra (preferred) or generatorClasspath, e.g.:
      • dependencies { openApiGeneratorExtra("com.acme:my-normalizer:1.0.0") }
      • openApiGenerate { generatorClasspath.from(files("libs/my-generator.jar")) }