Skip to content
Draft
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
ca3e8ec
fix(MergedSpecBuilder): merge path items by HTTP method instead of ov…
Picazsoo Jun 22, 2026
8972528
fix(kotlin-spring): fix Flow<String> and Flux<T> array return types f…
Picazsoo Jun 22, 2026
8aa4e27
Revert "fix(kotlin-spring): fix Flow<String> and Flux<T> array return…
Picazsoo Jun 22, 2026
818f196
fix(petstore): update operation IDs for pets endpoints to prevent col…
Picazsoo Jun 22, 2026
6d189e8
Merge branch 'master' into feature/merge-specs
Picazsoo Jun 30, 2026
6d1a54b
feat: add merge conflict strategy to MergedSpecBuilder for handling c…
Picazsoo Jun 30, 2026
fa2d521
feat: add merged file name and merge conflict strategy properties to …
Picazsoo Jun 30, 2026
6b16b12
feat: implement merge mode and conflict strategy options in MergedSpe…
Picazsoo Jun 30, 2026
f5f50a0
restore original behavior
Picazsoo Jun 30, 2026
aefbadd
update documentation
Picazsoo Jun 30, 2026
ff82951
escape asterisk
Picazsoo Jun 30, 2026
29e91ba
fix issue raised in CR
Picazsoo Jun 30, 2026
44bdaef
feat: enhance merge conflict handling and improve spec parsing logic
Picazsoo Jun 30, 2026
7a59aab
improve documentation
Picazsoo Jun 30, 2026
78d80be
feat: add support for merging explicit ordered list of spec files
Picazsoo Jun 30, 2026
fe3d29f
test: add comprehensive MergedSpecBuilder tests and fix path-level pa…
Picazsoo Jun 30, 2026
5ef1eec
refactor: rename inputSpecFilesOutputDir to mergedFileOutputDir for c…
Picazsoo Jun 30, 2026
e0ce9d0
feat: add inputSpecFiles explicit merge list support to Maven and CLI…
Picazsoo Jun 30, 2026
fcbf679
fix tests
Picazsoo Jun 30, 2026
13baea2
implement feedback from CR
Picazsoo Jun 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,14 @@ public class Generate extends OpenApiGeneratorCommand {
@Option(name = "--merged-spec-filename", title = "Name of resulted merged specs file (used along with --input-spec-root-directory option)")
private String mergedFileName;

@Option(name = "--merge-conflict-strategy", title = "Merge conflict strategy",
description = "Strategy when two specs define the same component/path+method with different definitions: WARN (default, keep first) or FAIL (abort). Only applies with --merge-mode DEEP.")
private String mergeConflictStrategy;

@Option(name = "--merge-mode", title = "Merge mode",
description = "How multiple spec files are merged: REF (default, original $ref-based) or DEEP (full inline merge with component deduplication).")
private String mergeMode;

@Option(name = {"-t", "--template-dir"}, title = "template directory",
description = "folder containing the template files")
private String templateDir;
Expand Down Expand Up @@ -346,8 +354,29 @@ public class Generate extends OpenApiGeneratorCommand {
@Override
public void execute() {
if (StringUtils.isNotBlank(inputSpecRootDirectory)) {
spec = new MergedSpecBuilder(inputSpecRootDirectory, StringUtils.isBlank(mergedFileName) ? "_merged_spec" : mergedFileName)
.buildMergedSpec();
MergedSpecBuilder builder = new MergedSpecBuilder(inputSpecRootDirectory, StringUtils.isBlank(mergedFileName) ? "_merged_spec" : mergedFileName);

MergedSpecBuilder.MergeMode resolvedMergeMode = MergedSpecBuilder.MergeMode.REF;
if (StringUtils.isNotBlank(mergeMode)) {
try {
resolvedMergeMode = MergedSpecBuilder.MergeMode.valueOf(mergeMode.toUpperCase(java.util.Locale.ROOT));
} catch (IllegalArgumentException e) {
System.err.println("[error] Invalid --merge-mode value '" + mergeMode + "'. Valid values are: REF, DEEP");
return;
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
}
builder.withMergeMode(resolvedMergeMode);
}

if (resolvedMergeMode == MergedSpecBuilder.MergeMode.DEEP && StringUtils.isNotBlank(mergeConflictStrategy)) {
try {
builder.withConflictStrategy(MergedSpecBuilder.MergeConflictStrategy.valueOf(mergeConflictStrategy.toUpperCase(java.util.Locale.ROOT)));
} catch (IllegalArgumentException e) {
System.err.println("[error] Invalid --merge-conflict-strategy value '" + mergeConflictStrategy + "'. Valid values are: WARN, FAIL");
return;
}
}

spec = builder.buildMergedSpec();
System.out.println("Merge input spec would be used - " + spec);
}

Expand Down
16 changes: 13 additions & 3 deletions modules/openapi-generator-gradle-plugin/README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -162,8 +162,18 @@ apply plugin: 'org.openapi.generator'

|mergedFileName
|String / Provider<String>
|None
|Name of the file that will contain all merged specs
|`merged`
|Name of the output file that will contain all merged specs (used with `inputSpecRootDirectory`)

|mergeMode
|String / Provider<String>
|`REF`
|How multiple spec files are merged. `REF` (default) preserves the original behaviour: generates a lightweight index spec where each path is a `$ref` pointing back to its source file — identical to the master behaviour. `DEEP` fully parses and inlines all paths and components into a single self-contained spec, with component deduplication and conflict detection controlled by `mergeConflictStrategy`.

|mergeConflictStrategy
|String / Provider<String>
|`WARN`
|How to handle name conflicts when `mergeMode` is `DEEP` and two specs define the same component (schema, response, etc.) or the same path+method with different definitions. `WARN` (default) keeps the first definition and logs a warning. `FAIL` throws an exception and aborts the build.

|remoteInputSpec
|String / Provider<String>
Expand Down Expand Up @@ -248,7 +258,7 @@ apply plugin: 'org.openapi.generator'
|forcedGenerateSchemas
|List / Provider<List>
|None
|Forces generation of the named schemas even when they are listed in `schemaMappings` or `importMappings` (which would normally suppress their generation). The primary use case is producing a *reference copy* of a hand-written class — for example, a custom enum that replaces a generated one — so you can assert in a test that the hand-written version has not diverged from what the generator would produce. List of schema names, e.g. `["MyEnum", "OtherSchema"]`. Use `["*"]` as a wildcard to force-generate *all* schemas that would otherwise be suppressed by a mapping.
|Forces generation of the named schemas even when they are listed in `schemaMappings` or `importMappings` (which would normally suppress their generation). The primary use case is producing a *reference copy* of a hand-written class — for example, a custom enum that replaces a generated one — so you can assert in a test that the hand-written version has not diverged from what the generator would produce. List of schema names, e.g. `["MyEnum", "OtherSchema"]`. Use `["\*"]` as a wildcard to force-generate *all* schemas that would otherwise be suppressed by a mapping.

|nameMappings
|Map / Provider<Map>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ class OpenApiGeneratorPlugin : Plugin<Project> {
inputSpec.set(generate.inputSpec)
inputSpecRootDirectory.set(generate.inputSpecRootDirectory)
inputSpecRootDirectorySkipMerge.set(generate.inputSpecRootDirectorySkipMerge)
mergedFileName.set(generate.mergedFileName)
mergeMode.set(generate.mergeMode)
mergeConflictStrategy.set(generate.mergeConflictStrategy)
remoteInputSpec.set(generate.remoteInputSpec)
templateDir.set(generate.templateDir)
templateResourcePath.set(generate.templateResourcePath)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,28 @@ open class OpenApiGeneratorGenerateExtension(private val project: Project) {
*/
val inputSpecRootDirectorySkipMerge = project.objects.property<Boolean>()

/**
* Name of the file that will contain all merged specs (used with inputSpecRootDirectory).
*
* Default: "merged"
*/
val mergedFileName = project.objects.property<String>()

/**
* How multiple spec files are merged. Accepted values: "REF" (default, original $ref-based
* shallow merge, backward-compatible) or "DEEP" (full inline merge with component
* deduplication and conflict detection).
*/
val mergeMode = project.objects.property<String>()

/**
* Strategy when two specs define the same component name or path+method with conflicting
* (non-identical) definitions. Accepted values: "WARN" (default, keep first definition and
* log a warning) or "FAIL" (throw an exception and abort the build). Only applies when
* mergeMode is "DEEP".
*/
val mergeConflictStrategy = project.objects.property<String>()

/**
* The remote Open API 2.0/3.x specification URL location.
*/
Expand Down Expand Up @@ -449,6 +471,9 @@ open class OpenApiGeneratorGenerateExtension(private val project: Project) {
fun applyDefaults() {
releaseNote.convention("Minor update")
inputSpecRootDirectorySkipMerge.convention(false)
mergedFileName.convention("merged")
mergeMode.convention("REF")
mergeConflictStrategy.convention("WARN")
modelNamePrefix.convention("")
modelNameSuffix.convention("")
apiNameSuffix.convention("")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,23 @@ abstract class GenerateTask : DefaultTask() {
@get:Optional
abstract val mergedFileName: Property<String>

/**
* How multiple spec files are merged. Accepted values: "REF" (default, original $ref-based
* shallow merge, backward-compatible) or "DEEP" (full inline merge with component
* deduplication and conflict detection).
*/
@get:Input
@get:Optional
abstract val mergeMode: Property<String>

/**
* Strategy when two specs define the same component name or path+method with conflicting
* definitions. Accepted values: "WARN" (default) or "FAIL". Only applies when mergeMode is "DEEP".
*/
@get:Input
@get:Optional
abstract val mergeConflictStrategy: Property<String>

/**
* The remote Open API 2.0/3.x specification URL location.
*/
Expand Down Expand Up @@ -873,6 +890,8 @@ abstract class GenerateTask : DefaultTask() {
init {
inputSpecRootDirectorySkipMerge.convention(false)
mergedFileName.convention("merged")
mergeMode.convention("REF")
mergeConflictStrategy.convention("WARN")
}

@Suppress("unused")
Expand All @@ -893,10 +912,28 @@ abstract class GenerateTask : DefaultTask() {

inputSpecRootDirectory.orNull?.let { inputDir ->
if (!inputSpecRootDirectorySkipMerge.get()) {
finalResolvedInputSpec = MergedSpecBuilder(
val resolvedMergeMode = try {
MergedSpecBuilder.MergeMode.valueOf(mergeMode.get().uppercase())
} catch (e: IllegalArgumentException) {
throw GradleException("Invalid mergeMode value '${mergeMode.get()}'. Valid values are: REF, DEEP")
}

val builder = MergedSpecBuilder(
inputDir.asFile.absolutePath,
mergedFileName.get()
).buildMergedSpec()
).withMergeMode(resolvedMergeMode)

if (resolvedMergeMode == MergedSpecBuilder.MergeMode.DEEP) {
try {
builder.withConflictStrategy(
MergedSpecBuilder.MergeConflictStrategy.valueOf(mergeConflictStrategy.get().uppercase())
)
} catch (e: IllegalArgumentException) {
throw GradleException("Invalid mergeConflictStrategy value '${mergeConflictStrategy.get()}'. Valid values are: WARN, FAIL")
}
}

finalResolvedInputSpec = builder.buildMergedSpec()
logger.info("Merge input spec used: {}", finalResolvedInputSpec)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ paths:
/v3/pets:
get:
summary: List all pets
operationId: listPets
operationId: listPetsV3
tags:
- pets
parameters:
Expand Down Expand Up @@ -41,7 +41,7 @@ paths:
$ref: "#/components/schemas/Error"
post:
summary: Create a pet
operationId: createPets
operationId: createPetsV3
tags:
- pets
responses:
Expand All @@ -56,7 +56,7 @@ paths:
/v3/pets/{petId}:
get:
summary: Info for a specific pet
operationId: showPetById
operationId: showPetByIdV3
tags:
- pets
parameters:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,22 @@ public class CodeGenMojo extends AbstractMojo {
@Parameter(name = "mergedFileInfoVersion", property = "openapi.generator.maven.plugin.mergedFileInfoVersion", defaultValue = "1.0.0")
private String mergedFileInfoVersion;

/**
* Strategy when two specs define the same component name or path+method with different
* definitions. Accepted values: WARN (default, keep the first definition and log a warning)
* or FAIL (abort the build with an error). Only applies when mergeMode is DEEP.
*/
@Parameter(name = "mergeConflictStrategy", property = "openapi.generator.maven.plugin.mergeConflictStrategy", defaultValue = "WARN")
private String mergeConflictStrategy;

/**
* How multiple spec files are merged. Accepted values: REF (default, original $ref-based
* shallow merge, backward-compatible) or DEEP (full inline merge with component
* deduplication and conflict detection).
*/
@Parameter(name = "mergeMode", property = "openapi.generator.maven.plugin.mergeMode", defaultValue = "REF")
private String mergeMode;

/**
* Git host, e.g. gitlab.com.
*/
Expand Down Expand Up @@ -582,9 +598,29 @@ public void execute() throws MojoExecutionException {
// make sure the path can be processed correct under Windows OS
inputSpecRootDirectory = inputSpecRootDirectory.replaceAll("\\\\", "/");

inputSpec = new MergedSpecBuilder(inputSpecRootDirectory, mergedFileName,
MergedSpecBuilder.MergeMode resolvedMergeMode;
try {
resolvedMergeMode = MergedSpecBuilder.MergeMode.valueOf(mergeMode.toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException e) {
throw new MojoExecutionException("Invalid mergeMode value '" + mergeMode
+ "'. Valid values are: REF, DEEP");
}

MergedSpecBuilder builder = new MergedSpecBuilder(inputSpecRootDirectory, mergedFileName,
mergedFileInfoName, mergedFileInfoDescription, mergedFileInfoVersion, auth)
.buildMergedSpec();
.withMergeMode(resolvedMergeMode);

if (resolvedMergeMode == MergedSpecBuilder.MergeMode.DEEP) {
try {
builder.withConflictStrategy(
MergedSpecBuilder.MergeConflictStrategy.valueOf(mergeConflictStrategy.toUpperCase(Locale.ROOT)));
} catch (IllegalArgumentException e) {
throw new MojoExecutionException("Invalid mergeConflictStrategy value '" + mergeConflictStrategy
+ "'. Valid values are: WARN, FAIL");
}
}

inputSpec = builder.buildMergedSpec();
LOGGER.info("Merge input spec would be used - {}", inputSpec);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,23 @@ public class ValidateMojo extends AbstractMojo {
*/
@Parameter(name = "mergedFileInfoVersion", property = "openapi.generator.maven.plugin.mergedFileInfoVersion", defaultValue = "1.0.0")
private String mergedFileInfoVersion;

/**
* Strategy when two specs define the same component name or path+method with different
* definitions. Accepted values: WARN (default, keep the first definition and log a warning)
* or FAIL (abort the build with an error). Only applies when mergeMode is DEEP.
*/
@Parameter(name = "mergeConflictStrategy", property = "openapi.generator.maven.plugin.mergeConflictStrategy", defaultValue = "WARN")
private String mergeConflictStrategy;

/**
* How multiple spec files are merged. Accepted values: REF (default, original $ref-based
* shallow merge, backward-compatible) or DEEP (full inline merge with component
* deduplication and conflict detection).
*/
@Parameter(name = "mergeMode", property = "openapi.generator.maven.plugin.mergeMode", defaultValue = "REF")
private String mergeMode;

/**
* The path to the collapsed single-file representation of the OpenAPI spec.
*/
Expand Down Expand Up @@ -183,10 +200,11 @@ public void execute() throws MojoExecutionException {
inputSpec = inputSpec[0].split("\\s*,\\s*");
}

mergeInDirectory().ifPresent(mergedSpec -> {
Optional<String> mergedSpecOpt = mergeInDirectory();
if (mergedSpecOpt.isPresent()) {
inputSpec = new String[1];
inputSpec[0] = mergedSpec;
});
inputSpec[0] = mergedSpecOpt.get();
}

try {
for (String oneInputSpec : inputSpec) {
Expand Down Expand Up @@ -244,14 +262,34 @@ private boolean shouldWeSkip() {
return false;
}

private Optional<String> mergeInDirectory() {
private Optional<String> mergeInDirectory() throws MojoExecutionException {
Optional<String> mergedSpec = Optional.empty();
if (StringUtils.isNotBlank(inputSpecRootDirectory)) {
inputSpecRootDirectory = replaceBackslashesToSlashes(inputSpecRootDirectory);

mergedSpec = Optional.of(new MergedSpecBuilder(inputSpecRootDirectory, mergedFileName,
MergedSpecBuilder.MergeMode resolvedMergeMode;
try {
resolvedMergeMode = MergedSpecBuilder.MergeMode.valueOf(mergeMode.toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException e) {
throw new MojoExecutionException("Invalid mergeMode value '" + mergeMode
+ "'. Valid values are: REF, DEEP");
}

MergedSpecBuilder builder = new MergedSpecBuilder(inputSpecRootDirectory, mergedFileName,
mergedFileInfoName, mergedFileInfoDescription, mergedFileInfoVersion, auth)
.buildMergedSpec());
.withMergeMode(resolvedMergeMode);

if (resolvedMergeMode == MergedSpecBuilder.MergeMode.DEEP) {
try {
builder.withConflictStrategy(
MergedSpecBuilder.MergeConflictStrategy.valueOf(mergeConflictStrategy.toUpperCase(Locale.ROOT)));
} catch (IllegalArgumentException e) {
throw new MojoExecutionException("Invalid mergeConflictStrategy value '" + mergeConflictStrategy
+ "'. Valid values are: WARN, FAIL");
}
}

mergedSpec = Optional.of(builder.buildMergedSpec());
LOGGER.info("Merge input spec would be used - {}", mergedSpec.get());
}
return mergedSpec;
Expand Down
Loading
Loading