diff --git a/modules/openapi-generator-cli/src/main/java/org/openapitools/codegen/cmd/Generate.java b/modules/openapi-generator-cli/src/main/java/org/openapitools/codegen/cmd/Generate.java index 2907c43afa68..1d42c5eb7e9c 100644 --- a/modules/openapi-generator-cli/src/main/java/org/openapitools/codegen/cmd/Generate.java +++ b/modules/openapi-generator-cli/src/main/java/org/openapitools/codegen/cmd/Generate.java @@ -68,6 +68,23 @@ 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 = "--input-spec-files", title = "Explicit spec files", + description = "Ordered list of spec files to merge (repeat the flag for each file). " + + "Alternative to --input-spec-root-directory; takes precedence when set.") + private List inputSpecFiles; + + @Option(name = "--merged-file-output-dir", title = "Merged file output directory", + description = "Directory where the merged spec file is written when --input-spec-files is used. Required when --input-spec-files is set.") + private String mergedFileOutputDir; + @Option(name = {"-t", "--template-dir"}, title = "template directory", description = "folder containing the template files") private String templateDir; @@ -345,9 +362,64 @@ public class Generate extends OpenApiGeneratorCommand { @Override public void execute() { - if (StringUtils.isNotBlank(inputSpecRootDirectory)) { - spec = new MergedSpecBuilder(inputSpecRootDirectory, StringUtils.isBlank(mergedFileName) ? "_merged_spec" : mergedFileName) - .buildMergedSpec(); + if (inputSpecFiles != null && !inputSpecFiles.isEmpty()) { + if (StringUtils.isBlank(mergedFileOutputDir)) { + System.err.println("[error] --merged-file-output-dir must be set when --input-spec-files is used"); + System.exit(1); + } + + MergedSpecBuilder builder = new MergedSpecBuilder( + inputSpecFiles, + mergedFileOutputDir, + 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"); + System.exit(1); + } + 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"); + System.exit(1); + } + } + + spec = builder.buildMergedSpec(); + System.out.println("Merged input spec from explicit file list: " + spec); + } else if (StringUtils.isNotBlank(inputSpecRootDirectory)) { + 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"); + System.exit(1); + } + 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"); + System.exit(1); + } + } + + spec = builder.buildMergedSpec(); System.out.println("Merge input spec would be used - " + spec); } diff --git a/modules/openapi-generator-gradle-plugin/README.adoc b/modules/openapi-generator-gradle-plugin/README.adoc index d45ab1f727b0..2af0046af242 100644 --- a/modules/openapi-generator-gradle-plugin/README.adoc +++ b/modules/openapi-generator-gradle-plugin/README.adoc @@ -162,8 +162,18 @@ apply plugin: 'org.openapi.generator' |mergedFileName |String / Provider -|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 +|`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 +|`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 @@ -248,7 +258,7 @@ apply plugin: 'org.openapi.generator' |forcedGenerateSchemas |List / Provider |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 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 7ee348700898..69d6af33b129 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 @@ -102,6 +102,14 @@ class OpenApiGeneratorPlugin : Plugin { inputSpec.set(generate.inputSpec) inputSpecRootDirectory.set(generate.inputSpecRootDirectory) inputSpecRootDirectorySkipMerge.set(generate.inputSpecRootDirectorySkipMerge) + inputSpecFiles.from(generate.inputSpecFiles) + mergedFileOutputDir.set(generate.mergedFileOutputDir) + mergedFileName.set(generate.mergedFileName) + mergedFileInfoName.set(generate.mergedFileInfoName) + mergedFileInfoDescription.set(generate.mergedFileInfoDescription) + mergedFileInfoVersion.set(generate.mergedFileInfoVersion) + mergeMode.set(generate.mergeMode) + mergeConflictStrategy.set(generate.mergeConflictStrategy) remoteInputSpec.set(generate.remoteInputSpec) templateDir.set(generate.templateDir) templateResourcePath.set(generate.templateResourcePath) @@ -172,4 +180,3 @@ class OpenApiGeneratorPlugin : Plugin { } - 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 114a8f2bb0b5..9e753e38f28f 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 @@ -17,7 +17,9 @@ package org.openapitools.generator.gradle.plugin.extensions import org.gradle.api.Project +import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFile import org.gradle.api.file.RegularFileProperty import org.gradle.kotlin.dsl.listProperty import org.gradle.kotlin.dsl.mapProperty @@ -73,6 +75,21 @@ open class OpenApiGeneratorGenerateExtension(private val project: Project) { */ val inputSpecRootDirectory: DirectoryProperty = project.objects.directoryProperty() + /** + * An explicit ordered list of spec files to merge. + * + * When set, the generator merges exactly these files in the given order, rather than scanning a + * directory. Use with [mergeMode] and [mergeConflictStrategy]. The merged output is written to + * [mergedFileOutputDir]. Takes precedence over [inputSpecRootDirectory] if both are set. + */ + val inputSpecFiles: ConfigurableFileCollection = project.objects.fileCollection() + + /** + * Directory where the merged spec file is written when [inputSpecFiles] is used. + * Must be set when [inputSpecFiles] is non-empty. + */ + val mergedFileOutputDir: DirectoryProperty = project.objects.directoryProperty() + /** * Skip bundling all spec files into a merged spec file, if true. * @@ -80,6 +97,31 @@ open class OpenApiGeneratorGenerateExtension(private val project: Project) { */ val inputSpecRootDirectorySkipMerge = project.objects.property() + /** + * Name of the file that will contain all merged specs (used with inputSpecRootDirectory). + * + * Default: "merged" + */ + val mergedFileName = project.objects.property() + val mergedFileInfoName = project.objects.property() + val mergedFileInfoDescription = project.objects.property() + val mergedFileInfoVersion = project.objects.property() + + /** + * 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() + + /** + * 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() + /** * The remote Open API 2.0/3.x specification URL location. */ @@ -449,6 +491,12 @@ open class OpenApiGeneratorGenerateExtension(private val project: Project) { fun applyDefaults() { releaseNote.convention("Minor update") inputSpecRootDirectorySkipMerge.convention(false) + mergedFileName.convention("merged") + mergedFileInfoName.convention("merged spec") + mergedFileInfoDescription.convention("merged spec") + mergedFileInfoVersion.convention("1.0.0") + mergeMode.convention("REF") + mergeConflictStrategy.convention("WARN") modelNamePrefix.convention("") modelNameSuffix.convention("") apiNameSuffix.convention("") @@ -482,7 +530,7 @@ open class OpenApiGeneratorGenerateExtension(private val project: Project) { fun setInputSpec(path: String) { if (path.isRemoteUri()) { remoteInputSpec.set(path) - inputSpec.set(null as File?) // Clear local file to prevent conflicts + inputSpec.set(null as RegularFile?) // Clear local file to prevent conflicts } else { inputSpec.set(project.layout.projectDirectory.file(path)) remoteInputSpec.set(null as String?) // Clear remote URL to prevent conflicts 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 3d279cd26044..d48ff4e3b150 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 @@ -19,9 +19,11 @@ package org.openapitools.generator.gradle.plugin.tasks import org.gradle.api.Action import org.gradle.api.DefaultTask import org.gradle.api.GradleException +import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.file.DirectoryProperty import org.gradle.api.file.FileSystemOperations import org.gradle.api.file.ProjectLayout +import org.gradle.api.file.RegularFile import org.gradle.api.file.RegularFileProperty import org.gradle.api.logging.Logging import org.gradle.api.provider.ListProperty @@ -392,6 +394,29 @@ abstract class GenerateTask : DefaultTask() { @get:PathSensitive(PathSensitivity.RELATIVE) abstract val inputSpecRootDirectory: DirectoryProperty + /** + * An explicit collection of spec files to merge, in the order they are declared. + * + * When set, the generator merges exactly these files rather than scanning a directory. + * Use with [mergeMode] and [mergeConflictStrategy]. The merged output is written to + * [mergedFileOutputDir]. + * + * Takes precedence over [inputSpecRootDirectory] if both are set. + */ + @get:InputFiles + @get:Optional + @get:SkipWhenEmpty + @get:PathSensitive(PathSensitivity.RELATIVE) + val inputSpecFiles: ConfigurableFileCollection = project.objects.fileCollection() + + /** + * Directory where the merged spec file is written when [inputSpecFiles] is used. + * Must be set when [inputSpecFiles] is non-empty. + */ + @get:OutputDirectory + @get:Optional + abstract val mergedFileOutputDir: DirectoryProperty + /** * Skip bundling all spec files into a merged spec file, if true. */ @@ -406,6 +431,44 @@ abstract class GenerateTask : DefaultTask() { @get:Optional abstract val mergedFileName: Property + /** + * Title to use in the info section of the merged spec. + */ + @get:Input + @get:Optional + abstract val mergedFileInfoName: Property + + /** + * Description to use in the info section of the merged spec. + */ + @get:Input + @get:Optional + abstract val mergedFileInfoDescription: Property + + /** + * Version to use in the info section of the merged spec. + */ + @get:Input + @get:Optional + abstract val mergedFileInfoVersion: Property + + /** + * 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 + + /** + * 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 + /** * The remote Open API 2.0/3.x specification URL location. */ @@ -873,6 +936,11 @@ abstract class GenerateTask : DefaultTask() { init { inputSpecRootDirectorySkipMerge.convention(false) mergedFileName.convention("merged") + mergedFileInfoName.convention("merged spec") + mergedFileInfoDescription.convention("merged spec") + mergedFileInfoVersion.convention("1.0.0") + mergeMode.convention("REF") + mergeConflictStrategy.convention("WARN") } @Suppress("unused") @@ -893,14 +961,71 @@ 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() + mergedFileName.get(), + mergedFileInfoName.get(), + mergedFileInfoDescription.get(), + mergedFileInfoVersion.get(), + null + ).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) } } + inputSpecFiles.takeIf { !it.isEmpty }?.let { files -> + val outputDirForMerge = mergedFileOutputDir.orNull?.asFile + ?: throw GradleException("mergedFileOutputDir must be set when using inputSpecFiles") + + 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( + files.map { it.absolutePath }, + outputDirForMerge.absolutePath, + mergedFileName.get(), + mergedFileInfoName.get(), + mergedFileInfoDescription.get(), + mergedFileInfoVersion.get(), + null + ).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("Merged spec from explicit file list: {}", finalResolvedInputSpec) + } + + cleanupOutput.orNull?.let { cleanup -> if (cleanup && outputDir.isPresent) { fs.delete { delete(outputDir) } @@ -1065,7 +1190,7 @@ abstract class GenerateTask : DefaultTask() { fun setInputSpecAsString(path: String) { if (path.isRemoteUri()) { remoteInputSpec.set(path) - inputSpec.set(null as File?) // Clear local file to prevent conflicts + inputSpec.set(null as RegularFile?) // Clear local file to prevent conflicts } else { inputSpec.set(layout.projectDirectory.file(path)) remoteInputSpec.set(null as String?) // Clear remote URL to prevent conflicts diff --git a/modules/openapi-generator-gradle-plugin/src/test/kotlin/TestBase.kt b/modules/openapi-generator-gradle-plugin/src/test/kotlin/TestBase.kt index fc1cf194e3d3..81dcd9f4843e 100644 --- a/modules/openapi-generator-gradle-plugin/src/test/kotlin/TestBase.kt +++ b/modules/openapi-generator-gradle-plugin/src/test/kotlin/TestBase.kt @@ -63,7 +63,7 @@ enum class PropertyFormat { */ fun String.toPropertyReference(format: PropertyFormat): String { return when (format) { - PropertyFormat.STRING -> """file("$this").absolutePath""" + PropertyFormat.STRING -> """"$this"""" PropertyFormat.FILE -> """file("$this")""" } } \ No newline at end of file diff --git a/modules/openapi-generator-gradle-plugin/src/test/resources/specs/petstore-v3.1.yaml b/modules/openapi-generator-gradle-plugin/src/test/resources/specs/petstore-v3.1.yaml index 0a8d3d15973c..426d253a62d9 100644 --- a/modules/openapi-generator-gradle-plugin/src/test/resources/specs/petstore-v3.1.yaml +++ b/modules/openapi-generator-gradle-plugin/src/test/resources/specs/petstore-v3.1.yaml @@ -10,7 +10,7 @@ paths: /v3/pets: get: summary: List all pets - operationId: listPets + operationId: listPetsV3 tags: - pets parameters: @@ -41,7 +41,7 @@ paths: $ref: "#/components/schemas/Error" post: summary: Create a pet - operationId: createPets + operationId: createPetsV3 tags: - pets responses: @@ -56,7 +56,7 @@ paths: /v3/pets/{petId}: get: summary: Info for a specific pet - operationId: showPetById + operationId: showPetByIdV3 tags: - pets parameters: diff --git a/modules/openapi-generator-maven-plugin/src/main/java/org/openapitools/codegen/plugin/CodeGenMojo.java b/modules/openapi-generator-maven-plugin/src/main/java/org/openapitools/codegen/plugin/CodeGenMojo.java index 2308efc620e3..b163b8578e92 100644 --- a/modules/openapi-generator-maven-plugin/src/main/java/org/openapitools/codegen/plugin/CodeGenMojo.java +++ b/modules/openapi-generator-maven-plugin/src/main/java/org/openapitools/codegen/plugin/CodeGenMojo.java @@ -137,6 +137,37 @@ public class CodeGenMojo extends AbstractMojo { @Parameter(name = "mergedFileInfoVersion", property = "openapi.generator.maven.plugin.mergedFileInfoVersion", defaultValue = "1.0.0") private String mergedFileInfoVersion; + /** + * Explicit ordered list of spec files to merge. When set, these files are merged in the given + * order instead of scanning inputSpecRootDirectory. Takes precedence over inputSpecRootDirectory. + * Use with mergedFileOutputDir (required), mergeMode, and mergeConflictStrategy. + */ + @Parameter(name = "inputSpecFiles", property = "openapi.generator.maven.plugin.inputSpecFiles") + private List inputSpecFiles; + + /** + * Directory where the merged spec file is written when inputSpecFiles is used. + * Required when inputSpecFiles is set. + */ + @Parameter(name = "mergedFileOutputDir", property = "openapi.generator.maven.plugin.mergedFileOutputDir") + private File mergedFileOutputDir; + + /** + * 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. */ @@ -573,18 +604,66 @@ public class CodeGenMojo extends AbstractMojo { @Override public void execute() throws MojoExecutionException { - if (StringUtils.isBlank(inputSpec) && StringUtils.isBlank(inputSpecRootDirectory)) { - LOGGER.error("inputSpec or inputSpecRootDirectory must be specified"); - throw new MojoExecutionException("inputSpec or inputSpecRootDirectory must be specified"); + if (StringUtils.isBlank(inputSpec) && StringUtils.isBlank(inputSpecRootDirectory) && (inputSpecFiles == null || inputSpecFiles.isEmpty())) { + LOGGER.error("inputSpec, inputSpecRootDirectory, or inputSpecFiles must be specified"); + throw new MojoExecutionException("inputSpec, inputSpecRootDirectory, or inputSpecFiles must be specified"); } - if (StringUtils.isNotBlank(inputSpecRootDirectory)) { + if (inputSpecFiles != null && !inputSpecFiles.isEmpty()) { + if (mergedFileOutputDir == null) { + throw new MojoExecutionException("mergedFileOutputDir must be set when inputSpecFiles is used"); + } + 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(inputSpecFiles, mergedFileOutputDir.getAbsolutePath(), + mergedFileName, mergedFileInfoName, mergedFileInfoDescription, mergedFileInfoVersion, auth) + .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("Merged input spec from explicit file list: {}", inputSpec); + } else if (StringUtils.isNotBlank(inputSpecRootDirectory)) { // 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); } diff --git a/modules/openapi-generator-maven-plugin/src/main/java/org/openapitools/codegen/plugin/ValidateMojo.java b/modules/openapi-generator-maven-plugin/src/main/java/org/openapitools/codegen/plugin/ValidateMojo.java index 27a44219edf4..961ddef58e5d 100644 --- a/modules/openapi-generator-maven-plugin/src/main/java/org/openapitools/codegen/plugin/ValidateMojo.java +++ b/modules/openapi-generator-maven-plugin/src/main/java/org/openapitools/codegen/plugin/ValidateMojo.java @@ -127,6 +127,38 @@ public class ValidateMojo extends AbstractMojo { */ @Parameter(name = "mergedFileInfoVersion", property = "openapi.generator.maven.plugin.mergedFileInfoVersion", defaultValue = "1.0.0") private String mergedFileInfoVersion; + + /** + * Explicit ordered list of spec files to merge. When set, these files are merged in the given + * order instead of scanning inputSpecRootDirectory. Takes precedence over inputSpecRootDirectory. + * Use with mergedFileOutputDir (required), mergeMode, and mergeConflictStrategy. + */ + @Parameter(name = "inputSpecFiles", property = "openapi.generator.maven.plugin.inputSpecFiles") + private List inputSpecFiles; + + /** + * Directory where the merged spec file is written when inputSpecFiles is used. + * Required when inputSpecFiles is set. + */ + @Parameter(name = "mergedFileOutputDir", property = "openapi.generator.maven.plugin.mergedFileOutputDir") + private File mergedFileOutputDir; + + /** + * 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. */ @@ -183,10 +215,11 @@ public void execute() throws MojoExecutionException { inputSpec = inputSpec[0].split("\\s*,\\s*"); } - mergeInDirectory().ifPresent(mergedSpec -> { + Optional mergedSpecOpt = mergeInDirectory(); + if (mergedSpecOpt.isPresent()) { inputSpec = new String[1]; - inputSpec[0] = mergedSpec; - }); + inputSpec[0] = mergedSpecOpt.get(); + } try { for (String oneInputSpec : inputSpec) { @@ -230,9 +263,9 @@ public void execute() throws MojoExecutionException { private void validateInputSpecInput() throws MojoExecutionException { boolean isInputSpecEmpty = (inputSpec == null || inputSpec.length == 0 || isBlank(inputSpec[0])); - if (isInputSpecEmpty && isBlank(inputSpecRootDirectory)) { - LOGGER.error("inputSpec or inputSpecRootDirectory must be specified"); - throw new MojoExecutionException("inputSpec or inputSpecRootDirectory must be specified"); + if (isInputSpecEmpty && isBlank(inputSpecRootDirectory) && (inputSpecFiles == null || inputSpecFiles.isEmpty())) { + LOGGER.error("inputSpec, inputSpecRootDirectory, or inputSpecFiles must be specified"); + throw new MojoExecutionException("inputSpec, inputSpecRootDirectory, or inputSpecFiles must be specified"); } } @@ -244,14 +277,67 @@ private boolean shouldWeSkip() { return false; } - private Optional mergeInDirectory() { + private Optional mergeInDirectory() throws MojoExecutionException { + // Explicit file list takes precedence + if (inputSpecFiles != null && !inputSpecFiles.isEmpty()) { + if (mergedFileOutputDir == null) { + throw new MojoExecutionException("mergedFileOutputDir must be set when inputSpecFiles is used"); + } + 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(inputSpecFiles, mergedFileOutputDir.getAbsolutePath(), + mergedFileName, mergedFileInfoName, mergedFileInfoDescription, mergedFileInfoVersion, auth) + .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"); + } + } + + String mergedSpec = builder.buildMergedSpec(); + LOGGER.info("Merged input spec from explicit file list: {}", mergedSpec); + return Optional.of(mergedSpec); + } + + // Directory scan mode Optional 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; diff --git a/modules/openapi-generator-maven-plugin/src/test/java/org/openapitools/codegen/plugin/CodeGenMojoTest.java b/modules/openapi-generator-maven-plugin/src/test/java/org/openapitools/codegen/plugin/CodeGenMojoTest.java index bc1b838341eb..18b6df86f1e8 100644 --- a/modules/openapi-generator-maven-plugin/src/test/java/org/openapitools/codegen/plugin/CodeGenMojoTest.java +++ b/modules/openapi-generator-maven-plugin/src/test/java/org/openapitools/codegen/plugin/CodeGenMojoTest.java @@ -270,7 +270,7 @@ public void testAnyInputSpecMustBeProvided() throws Exception { MojoExecutionException e = assertThrows(MojoExecutionException.class, mojo::execute); // THEN - assertEquals("inputSpec or inputSpecRootDirectory must be specified", e.getMessage()); + assertEquals("inputSpec, inputSpecRootDirectory, or inputSpecFiles must be specified", e.getMessage()); } public void testInputSpecRootDirectoryDoesNotRequireInputSpec() throws Exception { @@ -278,7 +278,7 @@ public void testInputSpecRootDirectoryDoesNotRequireInputSpec() throws Exception final Path tempDir = newTempFolder(); CodeGenMojo mojo = loadMojo(tempDir, "src/test/resources/default", "file", "executionId"); mojo.inputSpec = null; - mojo.inputSpecRootDirectory = "src/test/resources/default"; + mojo.inputSpecRootDirectory = new File("src/test/resources/default").getAbsolutePath(); // WHEN mojo.execute(); diff --git a/modules/openapi-generator-maven-plugin/src/test/java/org/openapitools/codegen/plugin/ValidateMojoTest.java b/modules/openapi-generator-maven-plugin/src/test/java/org/openapitools/codegen/plugin/ValidateMojoTest.java index 094ed754d34a..517f1bbec068 100644 --- a/modules/openapi-generator-maven-plugin/src/test/java/org/openapitools/codegen/plugin/ValidateMojoTest.java +++ b/modules/openapi-generator-maven-plugin/src/test/java/org/openapitools/codegen/plugin/ValidateMojoTest.java @@ -175,7 +175,7 @@ public void testExecute_WithMissingConfigurationInputs_ShouldThrowExplicitExcept // WHEN & THEN MojoExecutionException e = assertThrows(MojoExecutionException.class, mojo::execute); - assertEquals("inputSpec or inputSpecRootDirectory must be specified", e.getMessage()); + assertEquals("inputSpec, inputSpecRootDirectory, or inputSpecFiles must be specified", e.getMessage()); } /** diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/config/MergedSpecBuilder.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/config/MergedSpecBuilder.java index f56dc392e882..c154596a2439 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/config/MergedSpecBuilder.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/config/MergedSpecBuilder.java @@ -1,11 +1,18 @@ package org.openapitools.codegen.config; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import com.google.common.collect.ImmutableMap; import io.swagger.parser.OpenAPIParser; +import io.swagger.v3.core.util.Json; +import io.swagger.v3.core.util.Yaml; +import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.PathItem; +import io.swagger.v3.oas.models.info.Info; import io.swagger.v3.oas.models.servers.Server; +import io.swagger.v3.oas.models.parameters.Parameter; import io.swagger.v3.parser.core.models.ParseOptions; import org.apache.commons.lang3.ObjectUtils; import org.openapitools.codegen.auth.AuthParser; @@ -14,25 +21,71 @@ import java.io.File; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardOpenOption; import java.util.*; -import java.util.function.Predicate; import java.util.stream.Collectors; import java.util.stream.Stream; public class MergedSpecBuilder { + /** + * Controls how multiple spec files are merged together. + * + *
    + *
  • {@link #REF} (default) — original behaviour: generates a lightweight "index" spec + * where each path is a {@code $ref} pointing back to the originating spec file. + * Components stay in their original files and are resolved at generation time. + * This preserves backward compatibility.
  • + *
  • {@link #DEEP} — opt-in: fully parses and inlines all paths and components into a + * single self-contained spec. Detects and handles component/path+method conflicts + * according to {@link MergeConflictStrategy}.
  • + *
+ */ + public enum MergeMode { + /** Original $ref-based shallow merge (default, backward-compatible). */ + REF, + /** Full deep merge: inlines all components and paths into one spec. */ + DEEP + } + + /** + * Controls what happens when two specs define the same component name (schema, response, etc.) + * with different definitions during a {@link MergeMode#DEEP} merge. + * + *

This strategy applies to component/model conflicts only. Path+method overlaps (the same + * HTTP method on the same path in multiple specs) are always silently resolved by keeping the + * first definition, regardless of this setting.

+ */ + public enum MergeConflictStrategy { + /** Log a warning and keep the first definition (default). */ + WARN, + /** Throw a {@link RuntimeException} and abort the merge. */ + FAIL + } + private static final Logger LOGGER = LoggerFactory.getLogger(MergedSpecBuilder.class); + private static final Set SPEC_EXTENSIONS = new HashSet<>(Arrays.asList(".yaml", ".yml", ".json")); + + private static final ObjectMapper JSON_MAPPER = new ObjectMapper(); + private static final ObjectMapper YAML_MAPPER = new ObjectMapper(new YAMLFactory()); + private final String inputSpecRootDirectory; + /** Absolute paths to individual spec files to merge. Non-null when using list mode. */ + private final List inputSpecFiles; + /** Output directory for the merged file. Used in list mode instead of inputSpecRootDirectory. */ + private final String outputDirectory; private final String mergeFileName; private final String mergedFileInfoName; private final String mergedFileInfoDescription; private final String mergedFileInfoVersion; private final String auth; + private MergeMode mergeMode = MergeMode.REF; + private MergeConflictStrategy conflictStrategy = MergeConflictStrategy.WARN; public MergedSpecBuilder(final String rootDirectory, final String mergeFileName) { this(rootDirectory, mergeFileName, "merged spec", "merged spec", "1.0.0", null); @@ -41,6 +94,8 @@ public MergedSpecBuilder(final String rootDirectory, final String mergeFileName) public MergedSpecBuilder(final String rootDirectory, final String mergeFileName, final String mergedFileInfoName, final String mergedFileInfoDescription, final String mergedFileInfoVersion, final String auth) { this.inputSpecRootDirectory = rootDirectory; + this.inputSpecFiles = null; + this.outputDirectory = null; this.mergeFileName = mergeFileName; this.mergedFileInfoName = mergedFileInfoName; this.mergedFileInfoDescription = mergedFileInfoDescription; @@ -48,49 +103,195 @@ public MergedSpecBuilder(final String rootDirectory, final String mergeFileName, this.auth = auth; } + /** + * Constructs a builder that merges an explicit ordered list of spec files rather than + * scanning a directory. The merged result is written to {@code outputDirectory}. + * + * @param inputSpecFiles absolute paths to the spec files to merge, in merge order + * @param outputDirectory directory where the merged file will be written + * @param mergeFileName base name (without extension) for the merged output file + */ + public MergedSpecBuilder(final List inputSpecFiles, final String outputDirectory, final String mergeFileName) { + this(inputSpecFiles, outputDirectory, mergeFileName, "merged spec", "merged spec", "1.0.0", null); + } + + public MergedSpecBuilder(final List inputSpecFiles, final String outputDirectory, final String mergeFileName, + final String mergedFileInfoName, final String mergedFileInfoDescription, + final String mergedFileInfoVersion, final String auth) { + this.inputSpecRootDirectory = null; + this.inputSpecFiles = new ArrayList<>(inputSpecFiles); + this.outputDirectory = outputDirectory; + this.mergeFileName = mergeFileName; + this.mergedFileInfoName = mergedFileInfoName; + this.mergedFileInfoDescription = mergedFileInfoDescription; + this.mergedFileInfoVersion = mergedFileInfoVersion; + this.auth = auth; + } + + /** + * Sets the merge mode. {@link MergeMode#REF} (default) uses the original $ref-based approach + * for backward compatibility. {@link MergeMode#DEEP} fully inlines all components and paths. + * + * @return this builder, for chaining + */ + public MergedSpecBuilder withMergeMode(MergeMode mode) { + this.mergeMode = mode; + return this; + } + + /** + * Sets the strategy used when two specs define the same component name (schema, response, etc.) + * with conflicting definitions. Only applies when {@link MergeMode#DEEP} is active. + * + *

Path+method overlaps are unaffected by this setting — they are always silently resolved + * by keeping the first definition.

+ * + * @param strategy {@link MergeConflictStrategy#WARN} to log a warning and keep the first + * definition (default), or {@link MergeConflictStrategy#FAIL} to throw a + * {@link RuntimeException} and abort. + * @return this builder, for chaining + */ + public MergedSpecBuilder withConflictStrategy(MergeConflictStrategy strategy) { + this.conflictStrategy = strategy; + return this; + } + public String buildMergedSpec() { + if (inputSpecFiles != null) { + return buildMergedSpecFromList(); + } + return buildMergedSpecFromDirectory(); + } + + private String buildMergedSpecFromList() { + if (inputSpecFiles.isEmpty()) { + throw new RuntimeException("inputSpecFiles list is empty — nothing to merge"); + } + deleteMergedFileFromPreviousRun(); + LOGGER.info("Merging {} explicit spec files into {}", inputSpecFiles.size(), outputDirectory); + return buildMergedSpec(inputSpecFiles, outputDirectory); + } + + private String buildMergedSpecFromDirectory() { deleteMergedFileFromPreviousRun(); List specRelatedPaths = getAllSpecFilesInDirectory(); if (specRelatedPaths.isEmpty()) { throw new RuntimeException("Spec directory doesn't contain any specification"); } LOGGER.info("In spec root directory {} found specs {}", inputSpecRootDirectory, specRelatedPaths); + // Resolve relative paths to absolute so the shared build logic works uniformly + List absolutePaths = specRelatedPaths.stream() + .map(rel -> Paths.get(inputSpecRootDirectory, rel).toAbsolutePath().toString()) + .collect(Collectors.toList()); + return buildMergedSpec(absolutePaths, inputSpecRootDirectory); + } - String openapiVersion = null; - boolean isJson = false; + /** + * Core merge logic shared by both directory mode and list mode. + * + * @param absoluteSpecPaths absolute paths to each spec file to parse, in merge order + * @param outputDir directory where the merged output file will be written + */ + private String buildMergedSpec(List absoluteSpecPaths, String outputDir) { + ParsedSpecFiles parsed = parseSpecFiles(absoluteSpecPaths); + if (mergeMode == MergeMode.DEEP) { + return buildDeepMergedSpec(parsed, outputDir); + } + return buildRefMergedSpec(parsed, outputDir); + } + + // ------------------------------------------------------------------------- + // Shared parsing helper + // ------------------------------------------------------------------------- + + /** Holds the results of parsing a set of spec files. */ + private static class ParsedSpecFiles { + final List specs; + final List absolutePaths; // successfully parsed, in the same order as specs + final boolean isJson; + final String openapiVersion; + final List allServers; + + ParsedSpecFiles(List specs, List absolutePaths, boolean isJson, + String openapiVersion, List allServers) { + this.specs = specs; + this.absolutePaths = absolutePaths; + this.isJson = isJson; + this.openapiVersion = openapiVersion; + this.allServers = allServers; + } + } + + /** Parses each spec file given as an absolute path. */ + private ParsedSpecFiles parseSpecFiles(List absolutePaths) { ParseOptions options = new ParseOptions(); - options.setResolve(true); - List allPaths = new ArrayList<>(); + // Do NOT resolve — with resolve:true the swagger-parser inlines path-level parameters + // into each operation, making them invisible to mergePathItem. For deep merge the $refs + // in operations remain as '#/components/schemas/...' which resolve correctly in the merged + // output because all component schemas are merged into merged.getComponents(). + options.setResolve(false); + List specs = new ArrayList<>(); + List parsedPaths = new ArrayList<>(); List allServers = new ArrayList<>(); + boolean isJson = false; + String openapiVersion = null; - for (String specRelatedPath : specRelatedPaths) { - String specPath = inputSpecRootDirectory + File.separator + specRelatedPath; + for (String absolutePath : absolutePaths) { try { - LOGGER.info("Reading spec: {}", specPath); - + LOGGER.info("Reading spec: {}", absolutePath); OpenAPI result = new OpenAPIParser() - .readLocation(specPath, AuthParser.parse(auth), options) + .readLocation(absolutePath, AuthParser.parse(auth), options) .getOpenAPI(); - + if (result == null) { + LOGGER.error("Failed to read file: {}. It would be ignored", absolutePath); + continue; + } + if (specs.isEmpty() && absolutePath.toLowerCase(Locale.ROOT).endsWith(".json")) { + isJson = true; + } if (openapiVersion == null) { openapiVersion = result.getOpenapi(); - if (specRelatedPath.toLowerCase(Locale.ROOT).endsWith(".json")) { - isJson = true; - } } allServers.addAll(ObjectUtils.defaultIfNull(result.getServers(), Collections.emptyList())); - allPaths.add(new SpecWithPaths(specRelatedPath, result.getPaths().keySet())); + specs.add(result); + parsedPaths.add(absolutePath); } catch (Exception e) { - LOGGER.error("Failed to read file: {}. It would be ignored", specPath); + LOGGER.error("Failed to read file: {}. It would be ignored", absolutePath); } } - Map mergedSpec = generatedMergedSpec(openapiVersion, allPaths, allServers); - String mergedFilename = this.mergeFileName + (isJson ? ".json" : ".yaml"); - Path mergedFilePath = Paths.get(inputSpecRootDirectory, mergedFilename); + if (specs.isEmpty()) { + throw new RuntimeException("No valid specifications found to merge"); + } + + return new ParsedSpecFiles(specs, parsedPaths, isJson, openapiVersion, allServers); + } + + // ------------------------------------------------------------------------- + // REF mode — original $ref-based shallow merge (identical to master) + // ------------------------------------------------------------------------- + + private String buildRefMergedSpec(ParsedSpecFiles parsed, String outputDir) { + Path outDirPath = Paths.get(outputDir); + + List allPaths = new ArrayList<>(); + for (int i = 0; i < parsed.specs.size(); i++) { + io.swagger.v3.oas.models.Paths specPaths = parsed.specs.get(i).getPaths(); + Set pathKeys = specPaths != null ? specPaths.keySet() : Collections.emptySet(); + // Compute path relative to the output dir so $ref values are correct regardless of + // whether the spec files are in the same directory or specified as arbitrary paths. + String relPath = outDirPath.relativize(Paths.get(parsed.absolutePaths.get(i))) + .toString().replace('\\', '/'); + allPaths.add(new SpecWithPaths(relPath, pathKeys)); + } + + Map mergedSpec = generateRefMergedSpec(parsed.openapiVersion, allPaths, parsed.allServers); + String mergedFilename = this.mergeFileName + (parsed.isJson ? ".json" : ".yaml"); + Path mergedFilePath = outDirPath.resolve(mergedFilename); try { - ObjectMapper objectMapper = isJson ? new ObjectMapper() : new ObjectMapper(new YAMLFactory()); + Files.createDirectories(outDirPath); + ObjectMapper objectMapper = parsed.isJson ? JSON_MAPPER : YAML_MAPPER; Files.write(mergedFilePath, objectMapper.writeValueAsBytes(mergedSpec), StandardOpenOption.CREATE, StandardOpenOption.WRITE); } catch (IOException e) { throw new RuntimeException(e); @@ -99,17 +300,15 @@ public String buildMergedSpec() { return mergedFilePath.toString(); } - private Map generatedMergedSpec(String openapiVersion, List allPaths, List allServers) { + private Map generateRefMergedSpec(String openapiVersion, List allPaths, List allServers) { Map spec = generateHeader(openapiVersion, mergedFileInfoName, mergedFileInfoDescription, mergedFileInfoVersion, allServers); - Map paths = new HashMap<>(); + Map paths = new LinkedHashMap<>(); spec.put("paths", paths); for (SpecWithPaths specWithPaths : allPaths) { for (String path : specWithPaths.paths) { - String specRelatedPath = "./" + specWithPaths.specRelatedPath + "#/paths/" + path.replace("/", "~1"); - paths.put(path, ImmutableMap.of( - "$ref", specRelatedPath - )); + String encodedPath = path.replace("/", "~1"); + paths.put(path, ImmutableMap.of("$ref", "./" + specWithPaths.specRelatedPath.replace('\\', '/') + "#/paths/" + encodedPath)); } } @@ -117,33 +316,248 @@ private Map generatedMergedSpec(String openapiVersion, List generateHeader(String openapiVersion, String title, String description, String version, List allServers) { - Map map = new HashMap<>(); + Map map = new LinkedHashMap<>(); map.put("openapi", openapiVersion); - map.put("info", ImmutableMap.of( - "title", title, - "description", description, - "version", version - )); + map.put("info", ImmutableMap.of("title", title, "description", description, "version", version)); Set> servers = allServers.stream() .map(Server::getUrl) .distinct() .map(url -> ImmutableMap.of("url", url)) - .collect(Collectors.collectingAndThen(Collectors.toSet(), Optional::of)) - .filter(Predicate.not(Set::isEmpty)) - .orElseGet(() -> Collections.singleton(ImmutableMap.of("url", "http://localhost:8080"))); - + .collect(Collectors.toCollection(LinkedHashSet::new)); + if (servers.isEmpty()) { + servers = Collections.singleton(ImmutableMap.of("url", "http://localhost:8080")); + } map.put("servers", servers); - return map; } + private static class SpecWithPaths { + private final String specRelatedPath; + private final Set paths; + + private SpecWithPaths(final String specRelatedPath, final Set paths) { + this.specRelatedPath = specRelatedPath; + this.paths = paths; + } + } + + // ------------------------------------------------------------------------- + // DEEP mode — full inline merge with component conflict detection + // ------------------------------------------------------------------------- + + private String buildDeepMergedSpec(ParsedSpecFiles parsed, String outputDir) { + OpenAPI merged = mergeSpecs(parsed.specs, parsed.allServers); + + String mergedFilename = this.mergeFileName + (parsed.isJson ? ".json" : ".yaml"); + Path mergedFilePath = Paths.get(outputDir, mergedFilename); + + try { + Files.createDirectories(mergedFilePath.getParent()); + String content = parsed.isJson + ? Json.mapper().writerWithDefaultPrettyPrinter().writeValueAsString(merged) + : Yaml.mapper().writerWithDefaultPrettyPrinter().writeValueAsString(merged); + Files.write(mergedFilePath, content.getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE, StandardOpenOption.WRITE); + } catch (JsonProcessingException e) { + throw new RuntimeException("Failed to serialize merged spec", e); + } catch (IOException e) { + throw new RuntimeException(e); + } + + return mergedFilePath.toString(); + } + + /** + * Merges a list of parsed OpenAPI specs into a single spec. + * + *

Path items are merged by HTTP method: if two specs define the same URL path, their + * operations are combined (e.g. GET from one file + POST from another). If the same HTTP + * method already exists on a path, a {@link RuntimeException} is thrown — this is always + * a configuration error with no valid use case.

+ * + *

Component maps (schemas, responses, requestBodies, parameters, headers, examples, + * links, callbacks, securitySchemes) are merged by name. Structurally identical duplicates + * are silently deduplicated. A warning (or exception in FAIL mode) is raised when the same + * component name appears with different definitions; the first definition is kept.

+ * + *

Top-level {@code x-} vendor extensions are merged from all specs; the first definition + * wins on key conflicts.

+ */ + OpenAPI mergeSpecs(List specs, List allServers) { + OpenAPI merged = new OpenAPI(); + merged.openapi(specs.get(0).getOpenapi() != null ? specs.get(0).getOpenapi() : "3.0.3"); + + Info info = new Info() + .title(mergedFileInfoName) + .description(mergedFileInfoDescription) + .version(mergedFileInfoVersion); + merged.info(info); + + List distinctServerUrls = allServers.stream() + .map(Server::getUrl) + .filter(Objects::nonNull) + .distinct() + .collect(Collectors.toList()); + if (distinctServerUrls.isEmpty()) { + merged.addServersItem(new Server().url("http://localhost:8080")); + } else { + distinctServerUrls.forEach(url -> merged.addServersItem(new Server().url(url))); + } + + merged.setPaths(new io.swagger.v3.oas.models.Paths()); + merged.setComponents(new Components()); + + for (OpenAPI spec : specs) { + if (spec.getPaths() != null) { + spec.getPaths().forEach((pathKey, incomingPathItem) -> { + PathItem existing = merged.getPaths().get(pathKey); + if (existing == null) { + merged.getPaths().addPathItem(pathKey, incomingPathItem); + } else { + mergePathItem(existing, incomingPathItem, pathKey); + } + }); + } + if (spec.getComponents() != null) { + mergeComponents(merged.getComponents(), spec.getComponents()); + } + // Merge top-level x- vendor extensions (keep first on key conflict) + if (spec.getExtensions() != null) { + spec.getExtensions().forEach((key, value) -> { + if (merged.getExtensions() == null || !merged.getExtensions().containsKey(key)) { + merged.addExtension(key, value); + } + }); + } + } + + return merged; + } + + /** + * Merges HTTP method operations from {@code incoming} into {@code existing} for the same path URL. + * + *

Operations for methods not yet present in {@code existing} are added. If the same HTTP + * method already exists on a path, a {@link RuntimeException} is always thrown — even if the + * two definitions are identical. Unlike schema reuse (where identical duplicates are silently + * deduplicated), there is no valid reason for two spec files to both define the same HTTP method + * on the same path. An identical duplicate is still almost certainly a configuration error + * (e.g. the same file included twice, or an accidental copy), and failing loudly is safer than + * silently discarding one.

+ * + *

Path-level metadata from {@code incoming} is merged into {@code existing}:

+ *
    + *
  • Parameters: added if not already present by name+in (first wins on conflict)
  • + *
  • Servers: added if not already present by URL
  • + *
  • Extensions: added if not already present by key
  • + *
  • Summary and description: always kept from the first ({@code existing}) PathItem
  • + *
+ */ + private void mergePathItem(PathItem existing, PathItem incoming, String pathKey) { + if (incoming.readOperationsMap() == null) { + return; + } + incoming.readOperationsMap().forEach((method, operation) -> { + if (existing.readOperationsMap() != null && existing.readOperationsMap().containsKey(method)) { + throw new RuntimeException(String.format(Locale.ROOT, + "Path+method conflict during spec merge: %s %s is defined in multiple specs. " + + "Unlike schema reuse, duplicate HTTP methods on the same path are not valid — " + + "check that your spec files do not overlap.", + method, pathKey)); + } + existing.operation(method, operation); + }); + + // Merge path-level parameters (deduplicate by name+in, first wins) + if (incoming.getParameters() != null) { + List merged = existing.getParameters() != null + ? new ArrayList<>(existing.getParameters()) : new ArrayList<>(); + Set existingKeys = merged.stream() + .map(p -> p.getName() + ":" + p.getIn()) + .collect(Collectors.toSet()); + for (Parameter p : incoming.getParameters()) { + if (existingKeys.add(p.getName() + ":" + p.getIn())) { + merged.add(p); + } + } + existing.setParameters(merged); + } + + // Merge path-level servers (deduplicate by URL) + if (incoming.getServers() != null) { + List merged = existing.getServers() != null + ? new ArrayList<>(existing.getServers()) : new ArrayList<>(); + Set existingUrls = merged.stream() + .map(Server::getUrl).filter(Objects::nonNull).collect(Collectors.toSet()); + for (Server s : incoming.getServers()) { + if (s.getUrl() == null || existingUrls.add(s.getUrl())) { + merged.add(s); + } + } + existing.setServers(merged); + } + + // Merge path-level extensions (first wins on key conflict) + if (incoming.getExtensions() != null) { + incoming.getExtensions().forEach((key, value) -> { + if (existing.getExtensions() == null || !existing.getExtensions().containsKey(key)) { + existing.addExtension(key, value); + } + }); + } + } + + /** + * Merges all component maps from {@code source} into {@code target}. + * Identical definitions are silently deduplicated. Conflicting definitions (same name, different + * structure) generate a warning and keep the first definition. + */ + private void mergeComponents(Components target, Components source) { + mergeComponentMap(target.getSchemas(), source.getSchemas(), "schema", target::addSchemas); + mergeComponentMap(target.getResponses(), source.getResponses(), "response", target::addResponses); + mergeComponentMap(target.getRequestBodies(), source.getRequestBodies(), "requestBody", target::addRequestBodies); + mergeComponentMap(target.getParameters(), source.getParameters(), "parameter", target::addParameters); + mergeComponentMap(target.getHeaders(), source.getHeaders(), "header", target::addHeaders); + mergeComponentMap(target.getExamples(), source.getExamples(), "example", target::addExamples); + mergeComponentMap(target.getLinks(), source.getLinks(), "link", target::addLinks); + mergeComponentMap(target.getCallbacks(), source.getCallbacks(), "callback", target::addCallbacks); + mergeComponentMap(target.getSecuritySchemes(), source.getSecuritySchemes(), "securityScheme", target::addSecuritySchemes); + } + + private void mergeComponentMap(Map existing, Map incoming, + String typeName, java.util.function.BiConsumer adder) { + if (incoming == null) { + return; + } + incoming.forEach((name, value) -> { + if (existing != null && existing.containsKey(name)) { + if (!Objects.equals(existing.get(name), value)) { + String message = String.format(Locale.ROOT, + "Component %s name conflict during spec merge: '%s' is defined in multiple specs with different definitions. Keeping the first definition.", + typeName, name); + if (conflictStrategy == MergeConflictStrategy.FAIL) { + throw new RuntimeException(message); + } + LOGGER.warn(message); + } + // identical or keeping first — either way, skip + } else { + adder.accept(name, value); + } + }); + } + private List getAllSpecFilesInDirectory() { Path rootDirectory = new File(inputSpecRootDirectory).toPath(); try (Stream pathStream = Files.walk(rootDirectory)) { return pathStream .filter(path -> !Files.isDirectory(path)) + .filter(path -> { + String name = path.getFileName().toString().toLowerCase(Locale.ROOT); + return SPEC_EXTENSIONS.stream().anyMatch(name::endsWith); + }) .map(path -> rootDirectory.relativize(path).toString()) + .sorted() .collect(Collectors.toList()); } catch (IOException e) { throw new RuntimeException("Exception while listing files in spec root directory: " + inputSpecRootDirectory, e); @@ -151,23 +565,14 @@ private List getAllSpecFilesInDirectory() { } private void deleteMergedFileFromPreviousRun() { + String targetDir = (outputDirectory != null) ? outputDirectory : inputSpecRootDirectory; try { - Files.deleteIfExists(Paths.get(inputSpecRootDirectory + File.separator + mergeFileName + ".json")); - } catch (IOException e) { + Files.deleteIfExists(Paths.get(targetDir + File.separator + mergeFileName + ".json")); + } catch (IOException ignored) { } try { - Files.deleteIfExists(Paths.get(inputSpecRootDirectory + File.separator + mergeFileName + ".yaml")); - } catch (IOException e) { - } - } - - private static class SpecWithPaths { - private final String specRelatedPath; - private final Set paths; - - private SpecWithPaths(final String specRelatedPath, final Set paths) { - this.specRelatedPath = specRelatedPath; - this.paths = paths; + Files.deleteIfExists(Paths.get(targetDir + File.separator + mergeFileName + ".yaml")); + } catch (IOException ignored) { } } } diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/config/MergedSpecBuilderTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/config/MergedSpecBuilderTest.java index 42f9e0a547d1..b5850e3c1c11 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/config/MergedSpecBuilderTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/config/MergedSpecBuilderTest.java @@ -2,23 +2,33 @@ import com.google.common.collect.ImmutableMap; import io.swagger.parser.OpenAPIParser; +import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.PathItem; +import io.swagger.v3.oas.models.info.Info; import io.swagger.v3.parser.core.models.ParseOptions; import org.openapitools.codegen.ClientOptInput; import org.openapitools.codegen.DefaultGenerator; import org.openapitools.codegen.java.assertions.JavaFileAssert; import org.openapitools.codegen.languages.SpringCodegen; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import org.slf4j.LoggerFactory; import org.testng.annotations.Test; import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Paths; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; import java.util.Map; import java.util.function.Function; import java.util.stream.Collectors; import static org.openapitools.codegen.languages.SpringCodegen.*; +import static org.testng.Assert.*; public class MergedSpecBuilderTest { @@ -101,4 +111,627 @@ private void assertFilesFromMergedSpec(String mergedSpec) throws IOException { .assertMethod("getSpec2Field").hasReturnType("BigDecimal"); } -} + // ---- Path-collision tests ---- + + @Test + public void shouldMergeSpecsWithCollidingPaths_yaml() throws IOException { + shouldMergeSpecsWithCollidingPaths("yaml"); + } + + @Test + public void shouldMergeSpecsWithCollidingPaths_json() throws IOException { + shouldMergeSpecsWithCollidingPaths("json"); + } + + private void shouldMergeSpecsWithCollidingPaths(String fileExt) throws IOException { + File dir = Files.createTempDirectory("spec-collision").toFile().getCanonicalFile(); + dir.deleteOnExit(); + + Files.copy(Paths.get("src/test/resources/bugs/mergerTest/spec1." + fileExt), dir.toPath().resolve("spec1." + fileExt)); + Files.copy(Paths.get("src/test/resources/bugs/mergerTest/spec-collision." + fileExt), dir.toPath().resolve("spec-collision." + fileExt)); + + String mergedSpec = new MergedSpecBuilder(dir.getAbsolutePath().replace('\\', '/'), "_merged") + .buildMergedSpec(); + + ParseOptions parseOptions = new ParseOptions(); + parseOptions.setResolve(true); + OpenAPI openAPI = new OpenAPIParser().readLocation(mergedSpec, null, parseOptions).getOpenAPI(); + + assertNotNull(openAPI.getPaths(), "Merged spec must have paths"); + + // REF mode: same path in multiple specs — last file (alphabetically) wins + PathItem spec1Path = openAPI.getPaths().get("/spec1"); + assertNotNull(spec1Path, "/spec1 path must exist in merged spec"); + + // /collision path from spec-collision must also be present + assertNotNull(openAPI.getPaths().get("/collision"), "/collision path must exist in merged spec"); + } + + // ---- Vendor extensions tests ---- + + @Test + public void shouldPreserveVendorExtensions_yaml() throws IOException { + shouldPreserveVendorExtensions("yaml"); + } + + @Test + public void shouldPreserveVendorExtensions_json() throws IOException { + shouldPreserveVendorExtensions("json"); + } + + private void shouldPreserveVendorExtensions(String fileExt) throws IOException { + File dir = Files.createTempDirectory("spec-extensions").toFile().getCanonicalFile(); + dir.deleteOnExit(); + + Files.copy(Paths.get("src/test/resources/bugs/mergerTest/spec-extensions." + fileExt), dir.toPath().resolve("spec-extensions." + fileExt)); + + String mergedSpec = new MergedSpecBuilder(dir.getAbsolutePath().replace('\\', '/'), "_merged") + .buildMergedSpec(); + + ParseOptions parseOptions = new ParseOptions(); + parseOptions.setResolve(true); + OpenAPI openAPI = new OpenAPIParser().readLocation(mergedSpec, null, parseOptions).getOpenAPI(); + + assertNotNull(openAPI.getPaths(), "Merged spec must have paths"); + + PathItem extPath = openAPI.getPaths().get("/ext-path"); + assertNotNull(extPath, "/ext-path must exist"); + assertNotNull(extPath.getExtensions(), "Path-level extensions must be preserved"); + assertEquals(extPath.getExtensions().get("x-custom-path-ext"), "path-level-value", "x-custom-path-ext must be preserved on path"); + + assertNotNull(extPath.getGet(), "GET operation must exist on /ext-path"); + assertNotNull(extPath.getGet().getExtensions(), "Operation-level extensions must be preserved"); + assertEquals(extPath.getGet().getExtensions().get("x-custom-op-ext"), "operation-level-value", "x-custom-op-ext must be preserved on operation"); + + assertNotNull(openAPI.getComponents().getSchemas().get("ExtModel"), "ExtModel must exist"); + assertNotNull(openAPI.getComponents().getSchemas().get("ExtModel").getExtensions(), "Schema-level extensions must be preserved"); + assertEquals(openAPI.getComponents().getSchemas().get("ExtModel").getExtensions().get("x-custom-schema-ext"), "schema-level-value", "x-custom-schema-ext must be preserved on schema"); + } + + // ---- Component merging tests ---- + + @Test + public void shouldMergeComponentsFromBothSpecs_yaml() throws IOException { + shouldMergeComponentsFromBothSpecs("yaml"); + } + + @Test + public void shouldMergeComponentsFromBothSpecs_json() throws IOException { + shouldMergeComponentsFromBothSpecs("json"); + } + + private void shouldMergeComponentsFromBothSpecs(String fileExt) throws IOException { + File dir = Files.createTempDirectory("spec-components").toFile().getCanonicalFile(); + dir.deleteOnExit(); + + Files.copy(Paths.get("src/test/resources/bugs/mergerTest/spec1." + fileExt), dir.toPath().resolve("spec1." + fileExt)); + Files.copy(Paths.get("src/test/resources/bugs/mergerTest/spec2." + fileExt), dir.toPath().resolve("spec2." + fileExt)); + Files.copy(Paths.get("src/test/resources/bugs/mergerTest/spec-collision." + fileExt), dir.toPath().resolve("spec-collision." + fileExt)); + + String mergedSpec = new MergedSpecBuilder(dir.getAbsolutePath().replace('\\', '/'), "_merged") + .buildMergedSpec(); + + ParseOptions parseOptions = new ParseOptions(); + parseOptions.setResolve(true); + OpenAPI openAPI = new OpenAPIParser().readLocation(mergedSpec, null, parseOptions).getOpenAPI(); + + Map schemas = openAPI.getComponents().getSchemas(); + assertNotNull(schemas.get("Spec1Model"), "Spec1Model must be present"); + assertNotNull(schemas.get("Spec2Model"), "Spec2Model must be present"); + assertNotNull(schemas.get("CollisionModel"), "CollisionModel must be present"); + } + + // ---- Identical duplicate schema test ---- + + @Test + public void shouldHandleDuplicateIdenticalSchemas_yaml() throws IOException { + shouldHandleDuplicateIdenticalSchemas("yaml"); + } + + @Test + public void shouldHandleDuplicateIdenticalSchemas_json() throws IOException { + shouldHandleDuplicateIdenticalSchemas("json"); + } + + /** + * spec-collision defines Spec1Model identically to spec1 — same name, same structure. + * The merged result must contain exactly one Spec1Model without errors. + */ + private void shouldHandleDuplicateIdenticalSchemas(String fileExt) throws IOException { + File dir = Files.createTempDirectory("spec-dup-schema").toFile().getCanonicalFile(); + dir.deleteOnExit(); + + Files.copy(Paths.get("src/test/resources/bugs/mergerTest/spec1." + fileExt), dir.toPath().resolve("spec1." + fileExt)); + Files.copy(Paths.get("src/test/resources/bugs/mergerTest/spec-collision." + fileExt), dir.toPath().resolve("spec-collision." + fileExt)); + + // Must not throw + String mergedSpec = new MergedSpecBuilder(dir.getAbsolutePath().replace('\\', '/'), "_merged") + .buildMergedSpec(); + + ParseOptions parseOptions = new ParseOptions(); + parseOptions.setResolve(true); + OpenAPI openAPI = new OpenAPIParser().readLocation(mergedSpec, null, parseOptions).getOpenAPI(); + + // Spec1Model is defined in both spec1 and spec-collision with identical structure + assertNotNull(openAPI.getComponents().getSchemas().get("Spec1Model"), "Spec1Model must be present exactly once"); + } + + // ---- Non-spec file filter test ---- + + @Test + public void shouldIgnoreNonSpecFiles_yaml() throws IOException { + shouldIgnoreNonSpecFiles("yaml"); + } + + @Test + public void shouldIgnoreNonSpecFiles_json() throws IOException { + shouldIgnoreNonSpecFiles("json"); + } + + private void shouldIgnoreNonSpecFiles(String fileExt) throws IOException { + File dir = Files.createTempDirectory("spec-noext").toFile().getCanonicalFile(); + dir.deleteOnExit(); + + Files.copy(Paths.get("src/test/resources/bugs/mergerTest/spec1." + fileExt), dir.toPath().resolve("spec1." + fileExt)); + // Copy the non-spec .txt file into the same directory + Files.copy(Paths.get("src/test/resources/bugs/mergerTest/spec-noext.txt"), dir.toPath().resolve("spec-noext.txt")); + + // Must not throw despite the .txt file being present + String mergedSpec = new MergedSpecBuilder(dir.getAbsolutePath().replace('\\', '/'), "_merged") + .buildMergedSpec(); + + ParseOptions parseOptions = new ParseOptions(); + parseOptions.setResolve(true); + OpenAPI openAPI = new OpenAPIParser().readLocation(mergedSpec, null, parseOptions).getOpenAPI(); + + // Spec from spec1 must be present; no error from the .txt file + assertNotNull(openAPI.getPaths().get("/spec1"), "/spec1 path must be present"); + assertNotNull(openAPI.getComponents().getSchemas().get("Spec1Model"), "Spec1Model must be present"); + } + + // ---- Schema name conflict warning test ---- + + @Test + public void shouldWarnOnSchemaNameConflict_yaml() throws IOException { + shouldWarnOnSchemaNameConflict("yaml"); + } + + @Test + public void shouldWarnOnSchemaNameConflict_json() throws IOException { + shouldWarnOnSchemaNameConflict("json"); + } + + /** + * spec1 and spec-schema-conflict both define Spec1Model but with different properties. + * The merge must keep the first definition and emit a WARN log. + */ + private void shouldWarnOnSchemaNameConflict(String fileExt) throws IOException { + ch.qos.logback.classic.Logger logger = + (ch.qos.logback.classic.Logger) LoggerFactory.getLogger(MergedSpecBuilder.class); + ListAppender listAppender = new ListAppender<>(); + listAppender.start(); + logger.addAppender(listAppender); + + try { + File dir = Files.createTempDirectory("spec-schema-conflict").toFile().getCanonicalFile(); + dir.deleteOnExit(); + + Files.copy(Paths.get("src/test/resources/bugs/mergerTest/spec1." + fileExt), dir.toPath().resolve("spec1." + fileExt)); + Files.copy(Paths.get("src/test/resources/bugs/mergerTest/spec-schema-conflict." + fileExt), dir.toPath().resolve("spec-schema-conflict." + fileExt)); + + String mergedSpec = new MergedSpecBuilder(dir.getAbsolutePath().replace('\\', '/'), "_merged") + .withMergeMode(MergedSpecBuilder.MergeMode.DEEP) + .buildMergedSpec(); + + ParseOptions parseOptions = new ParseOptions(); + parseOptions.setResolve(true); + OpenAPI openAPI = new OpenAPIParser().readLocation(mergedSpec, null, parseOptions).getOpenAPI(); + + // First alphabetical file (spec-schema-conflict: differentField) is kept + assertNotNull(openAPI.getComponents().getSchemas().get("Spec1Model"), "Spec1Model must be present"); + assertNotNull(openAPI.getComponents().getSchemas().get("Spec1Model").getProperties().get("differentField"), + "differentField (from first-alphabetical spec) must be kept"); + assertNull(openAPI.getComponents().getSchemas().get("Spec1Model").getProperties().get("spec1Field"), + "spec1Field (from second spec) must NOT be present"); + + // A WARN about the conflict must have been logged + List warnLogs = listAppender.list.stream() + .filter(e -> e.getLevel() == ch.qos.logback.classic.Level.WARN) + .filter(e -> e.getFormattedMessage().contains("Spec1Model")) + .collect(Collectors.toList()); + assertFalse(warnLogs.isEmpty(), "A WARN log about the Spec1Model name conflict must be emitted"); + } finally { + logger.detachAppender(listAppender); + } + } + + // ---- FAIL conflict strategy tests ---- + + @Test + public void shouldFailOnSchemaNameConflictWithFailStrategy_yaml() throws IOException { + shouldFailOnSchemaNameConflictWithFailStrategy("yaml"); + } + + @Test + public void shouldFailOnSchemaNameConflictWithFailStrategy_json() throws IOException { + shouldFailOnSchemaNameConflictWithFailStrategy("json"); + } + + private void shouldFailOnSchemaNameConflictWithFailStrategy(String fileExt) throws IOException { + File dir = Files.createTempDirectory("spec-schema-conflict-fail").toFile().getCanonicalFile(); + dir.deleteOnExit(); + + Files.copy(Paths.get("src/test/resources/bugs/mergerTest/spec1." + fileExt), dir.toPath().resolve("spec1." + fileExt)); + Files.copy(Paths.get("src/test/resources/bugs/mergerTest/spec-schema-conflict." + fileExt), dir.toPath().resolve("spec-schema-conflict." + fileExt)); + + try { + new MergedSpecBuilder(dir.getAbsolutePath().replace('\\', '/'), "_merged") + .withMergeMode(MergedSpecBuilder.MergeMode.DEEP) + .withConflictStrategy(MergedSpecBuilder.MergeConflictStrategy.FAIL) + .buildMergedSpec(); + fail("Expected RuntimeException due to schema name conflict with FAIL strategy"); + } catch (RuntimeException e) { + assertTrue(e.getMessage().contains("Spec1Model"), "Exception message must mention the conflicting schema name"); + } + } + + @Test + public void shouldFailOnPathMethodOverlap_yaml() throws IOException { + shouldFailOnPathMethodOverlap("yaml"); + } + + @Test + public void shouldFailOnPathMethodOverlap_json() throws IOException { + shouldFailOnPathMethodOverlap("json"); + } + + /** + * When the same path+method appears in two specs, the merge must always fail — there is no + * valid use case for duplicate HTTP methods on the same path across spec files. + */ + private void shouldFailOnPathMethodOverlap(String fileExt) throws IOException { + File dir = Files.createTempDirectory("spec-path-overlap").toFile().getCanonicalFile(); + dir.deleteOnExit(); + + // Copy spec1 twice — both define the same paths and methods + Files.copy(Paths.get("src/test/resources/bugs/mergerTest/spec1." + fileExt), dir.toPath().resolve("spec1." + fileExt)); + Files.copy(Paths.get("src/test/resources/bugs/mergerTest/spec1." + fileExt), dir.toPath().resolve("spec1-duplicate." + fileExt)); + + try { + new MergedSpecBuilder(dir.getAbsolutePath().replace('\\', '/'), "_merged") + .withMergeMode(MergedSpecBuilder.MergeMode.DEEP) + .buildMergedSpec(); + fail("Expected RuntimeException due to duplicate path+method across specs"); + } catch (RuntimeException e) { + assertTrue(e.getMessage().contains("Path+method conflict"), + "Exception message must mention the path+method conflict"); + } + } + // ======================================================================== + // DEEP mode — basic and structural tests + // ======================================================================== + + @Test + public void shouldDeepMergeNonConflictingSpecs_yaml() throws IOException { + shouldDeepMergeNonConflictingSpecs("yaml"); + } + + @Test + public void shouldDeepMergeNonConflictingSpecs_json() throws IOException { + shouldDeepMergeNonConflictingSpecs("json"); + } + + /** DEEP mode with spec1+spec2 (no conflicts): both paths and both schemas must be inlined. */ + private void shouldDeepMergeNonConflictingSpecs(String fileExt) throws IOException { + File dir = Files.createTempDirectory("deep-basic").toFile().getCanonicalFile(); + dir.deleteOnExit(); + Files.copy(Paths.get("src/test/resources/bugs/mergerTest/spec1." + fileExt), dir.toPath().resolve("spec1." + fileExt)); + Files.copy(Paths.get("src/test/resources/bugs/mergerTest/spec2." + fileExt), dir.toPath().resolve("spec2." + fileExt)); + String mergedSpec = new MergedSpecBuilder(dir.getAbsolutePath().replace('\\', '/'), "_merged") + .withMergeMode(MergedSpecBuilder.MergeMode.DEEP).buildMergedSpec(); + ParseOptions opts = new ParseOptions(); opts.setResolve(true); + OpenAPI openAPI = new OpenAPIParser().readLocation(mergedSpec, null, opts).getOpenAPI(); + assertNotNull(openAPI.getPaths().get("/spec1"), "/spec1 must be present"); + assertNotNull(openAPI.getPaths().get("/spec2"), "/spec2 must be present"); + assertNotNull(openAPI.getComponents().getSchemas().get("Spec1Model"), "Spec1Model must be inlined"); + assertNotNull(openAPI.getComponents().getSchemas().get("Spec2Model"), "Spec2Model must be inlined"); + } + + @Test + public void shouldDeepMergeCollidingPathDifferentMethods_yaml() throws IOException { + shouldDeepMergeCollidingPathDifferentMethods("yaml"); + } + + @Test + public void shouldDeepMergeCollidingPathDifferentMethods_json() throws IOException { + shouldDeepMergeCollidingPathDifferentMethods("json"); + } + + /** + * spec1 defines GET /spec1; spec-collision defines POST /spec1 and GET /collision. + * DEEP merge must combine both methods on /spec1 and include /collision. + */ + private void shouldDeepMergeCollidingPathDifferentMethods(String fileExt) throws IOException { + File dir = Files.createTempDirectory("deep-path-methods").toFile().getCanonicalFile(); + dir.deleteOnExit(); + Files.copy(Paths.get("src/test/resources/bugs/mergerTest/spec1." + fileExt), dir.toPath().resolve("spec1." + fileExt)); + Files.copy(Paths.get("src/test/resources/bugs/mergerTest/spec-collision." + fileExt), dir.toPath().resolve("spec-collision." + fileExt)); + String mergedSpec = new MergedSpecBuilder(dir.getAbsolutePath().replace('\\', '/'), "_merged") + .withMergeMode(MergedSpecBuilder.MergeMode.DEEP).buildMergedSpec(); + ParseOptions opts = new ParseOptions(); opts.setResolve(true); + OpenAPI openAPI = new OpenAPIParser().readLocation(mergedSpec, null, opts).getOpenAPI(); + PathItem spec1Path = openAPI.getPaths().get("/spec1"); + assertNotNull(spec1Path, "/spec1 must be present"); + assertNotNull(spec1Path.getGet(), "GET /spec1 from spec1 must be present"); + assertNotNull(spec1Path.getPost(), "POST /spec1 from spec-collision must be present"); + assertNotNull(openAPI.getPaths().get("/collision"), "/collision must be present"); + assertNotNull(openAPI.getComponents().getSchemas().get("CollisionModel"), "CollisionModel must be inlined"); + } + + @Test + public void shouldDeepMergeDeduplicatesIdenticalSchemasSilently_yaml() throws IOException { + shouldDeepMergeDeduplicatesIdenticalSchemasSilently("yaml"); + } + + @Test + public void shouldDeepMergeDeduplicatesIdenticalSchemasSilently_json() throws IOException { + shouldDeepMergeDeduplicatesIdenticalSchemasSilently("json"); + } + + /** + * spec1 and spec-collision both define Spec1Model with identical structure. + * DEEP merge must succeed with no exception and no WARN log — identical duplicates are + * silently deduplicated, unlike conflicting definitions which produce a warning. + */ + private void shouldDeepMergeDeduplicatesIdenticalSchemasSilently(String fileExt) throws IOException { + ch.qos.logback.classic.Logger logger = + (ch.qos.logback.classic.Logger) LoggerFactory.getLogger(MergedSpecBuilder.class); + ListAppender listAppender = new ListAppender<>(); + listAppender.start(); + logger.addAppender(listAppender); + try { + File dir = Files.createTempDirectory("deep-dedup").toFile().getCanonicalFile(); + dir.deleteOnExit(); + Files.copy(Paths.get("src/test/resources/bugs/mergerTest/spec1." + fileExt), dir.toPath().resolve("spec1." + fileExt)); + Files.copy(Paths.get("src/test/resources/bugs/mergerTest/spec-collision." + fileExt), dir.toPath().resolve("spec-collision." + fileExt)); + String mergedSpec = new MergedSpecBuilder(dir.getAbsolutePath().replace('\\', '/'), "_merged") + .withMergeMode(MergedSpecBuilder.MergeMode.DEEP).buildMergedSpec(); + ParseOptions opts = new ParseOptions(); opts.setResolve(true); + OpenAPI openAPI = new OpenAPIParser().readLocation(mergedSpec, null, opts).getOpenAPI(); + assertNotNull(openAPI.getComponents().getSchemas().get("Spec1Model"), "Spec1Model must be present"); + long warnCount = listAppender.list.stream() + .filter(e -> e.getLevel() == ch.qos.logback.classic.Level.WARN) + .filter(e -> e.getFormattedMessage().contains("Spec1Model")) + .count(); + assertEquals(warnCount, 0L, "Identical duplicate schemas must NOT produce a WARN"); + } finally { + logger.detachAppender(listAppender); + } + } + + @Test + public void shouldDeepMergePreservesExtensions_yaml() throws IOException { + shouldDeepMergePreservesExtensions("yaml"); + } + + @Test + public void shouldDeepMergePreservesExtensions_json() throws IOException { + shouldDeepMergePreservesExtensions("json"); + } + + /** DEEP mode must preserve path-level, operation-level, and schema-level x- extensions. */ + private void shouldDeepMergePreservesExtensions(String fileExt) throws IOException { + File dir = Files.createTempDirectory("deep-ext").toFile().getCanonicalFile(); + dir.deleteOnExit(); + Files.copy(Paths.get("src/test/resources/bugs/mergerTest/spec-extensions." + fileExt), dir.toPath().resolve("spec-extensions." + fileExt)); + String mergedSpec = new MergedSpecBuilder(dir.getAbsolutePath().replace('\\', '/'), "_merged") + .withMergeMode(MergedSpecBuilder.MergeMode.DEEP).buildMergedSpec(); + ParseOptions opts = new ParseOptions(); opts.setResolve(true); + OpenAPI openAPI = new OpenAPIParser().readLocation(mergedSpec, null, opts).getOpenAPI(); + PathItem extPath = openAPI.getPaths().get("/ext-path"); + assertNotNull(extPath, "/ext-path must be present"); + assertEquals(extPath.getExtensions().get("x-custom-path-ext"), "path-level-value", + "path-level extension must be preserved in DEEP mode"); + assertEquals(extPath.getGet().getExtensions().get("x-custom-op-ext"), "operation-level-value", + "operation-level extension must be preserved in DEEP mode"); + assertEquals(openAPI.getComponents().getSchemas().get("ExtModel").getExtensions().get("x-custom-schema-ext"), "schema-level-value", + "schema-level extension must be preserved in DEEP mode"); + } + + @Test + public void shouldDeepMergeMergesTopLevelVendorExtensions() { + OpenAPI spec1 = new OpenAPI().openapi("3.0.3").info(new Info().title("s1").version("1.0.0")); + spec1.addExtension("x-team", "platform"); + spec1.addExtension("x-shared", "first-wins"); + spec1.setPaths(new io.swagger.v3.oas.models.Paths()); + spec1.setComponents(new Components()); + OpenAPI spec2 = new OpenAPI().openapi("3.0.3").info(new Info().title("s2").version("1.0.0")); + spec2.addExtension("x-service", "users"); + spec2.addExtension("x-shared", "MUST_NOT_OVERWRITE"); + spec2.setPaths(new io.swagger.v3.oas.models.Paths()); + spec2.setComponents(new Components()); + MergedSpecBuilder builder = new MergedSpecBuilder(".", "_merged"); + OpenAPI merged = builder.mergeSpecs(Arrays.asList(spec1, spec2), Collections.emptyList()); + assertNotNull(merged.getExtensions(), "Merged spec must have top-level extensions"); + assertEquals(merged.getExtensions().get("x-team"), "platform", "x-team from spec1 must be present"); + assertEquals(merged.getExtensions().get("x-service"), "users", "x-service from spec2 must be present"); + assertEquals(merged.getExtensions().get("x-shared"), "first-wins", "first definition must win on key conflict"); + } + + @Test + public void shouldDeepMergeMergesPathLevelMetadata_yaml() throws IOException { + shouldDeepMergeMergesPathLevelMetadata("yaml"); + } + + @Test + public void shouldDeepMergeMergesPathLevelMetadata_json() throws IOException { + shouldDeepMergeMergesPathLevelMetadata("json"); + } + + /** + * spec1 defines GET /spec1 with no path-level params. + * spec-pathlevel defines PATCH /spec1 with a path-level X-Request-ID header param and x-path-ext extension. + * Using explicit file list with spec1 first (so spec-pathlevel is the "incoming" PathItem), + * DEEP merge must copy the path-level parameter and extension from the incoming PathItem. + */ + private void shouldDeepMergeMergesPathLevelMetadata(String fileExt) throws IOException { + File inputDir = Files.createTempDirectory("deep-pathlevel-in").toFile().getCanonicalFile(); + inputDir.deleteOnExit(); + File outputDir = Files.createTempDirectory("deep-pathlevel-out").toFile().getCanonicalFile(); + outputDir.deleteOnExit(); + java.nio.file.Path spec1Path = inputDir.toPath().resolve("spec1." + fileExt); + java.nio.file.Path specPathLevel = inputDir.toPath().resolve("spec-pathlevel." + fileExt); + Files.copy(Paths.get("src/test/resources/bugs/mergerTest/spec1." + fileExt), spec1Path); + Files.copy(Paths.get("src/test/resources/bugs/mergerTest/spec-pathlevel." + fileExt), specPathLevel); + // spec1 first: creates /spec1 PathItem with GET; spec-pathlevel second: incoming PATCH + path-level metadata + String mergedSpec = new MergedSpecBuilder( + Arrays.asList(spec1Path.toAbsolutePath().toString(), specPathLevel.toAbsolutePath().toString()), + outputDir.getAbsolutePath(), "_merged" + ).withMergeMode(MergedSpecBuilder.MergeMode.DEEP).buildMergedSpec(); + // Verify the merged file actually contains the parameter before re-parsing + String mergedContent = new String(java.nio.file.Files.readAllBytes(Paths.get(mergedSpec)), java.nio.charset.StandardCharsets.UTF_8); + assertTrue(mergedContent.contains("X-Request-ID"), + "Merged file must contain X-Request-ID in serialized form"); + assertTrue(mergedContent.contains("x-path-ext"), + "Merged file must contain x-path-ext extension in serialized form"); + + // Parse without resolve so path-level parameters aren't inlined into operations + OpenAPI openAPI = new OpenAPIParser().readLocation(mergedSpec, null, new ParseOptions()).getOpenAPI(); + PathItem spec1PathItem = openAPI.getPaths().get("/spec1"); + assertNotNull(spec1PathItem, "/spec1 must be present"); + assertNotNull(spec1PathItem.getGet(), "GET /spec1 from spec1 must be present"); + assertNotNull(spec1PathItem.getPatch(), "PATCH /spec1 from spec-pathlevel must be present"); + assertNotNull(spec1PathItem.getParameters(), "Path-level parameters must be present after merge"); + assertTrue(spec1PathItem.getParameters().stream() + .anyMatch(p -> "X-Request-ID".equals(p.getName()) && "header".equals(p.getIn())), + "X-Request-ID path-level parameter from incoming spec must be merged"); + assertNotNull(spec1PathItem.getExtensions(), "Path-level extensions must be present after merge"); + assertEquals(spec1PathItem.getExtensions().get("x-path-ext"), "path-level-extension-from-incoming", + "x-path-ext path-level extension from incoming spec must be merged"); + } + + // ======================================================================== + // Explicit file list constructor tests + // ======================================================================== + + @Test + public void shouldMergeExplicitFileListRefMode_yaml() throws IOException { + shouldMergeExplicitFileListRefMode("yaml"); + } + + @Test + public void shouldMergeExplicitFileListRefMode_json() throws IOException { + shouldMergeExplicitFileListRefMode("json"); + } + + /** + * REF mode with explicit file list: files live in a separate input dir, output goes to a + * different dir. The merged spec must correctly reference all paths via $ref and resolve cleanly. + */ + private void shouldMergeExplicitFileListRefMode(String fileExt) throws IOException { + File inputDir = Files.createTempDirectory("list-ref-in").toFile().getCanonicalFile(); + inputDir.deleteOnExit(); + File outputDir = Files.createTempDirectory("list-ref-out").toFile().getCanonicalFile(); + outputDir.deleteOnExit(); + java.nio.file.Path spec1Path = inputDir.toPath().resolve("spec1." + fileExt); + java.nio.file.Path spec2Path = inputDir.toPath().resolve("spec2." + fileExt); + Files.copy(Paths.get("src/test/resources/bugs/mergerTest/spec1." + fileExt), spec1Path); + Files.copy(Paths.get("src/test/resources/bugs/mergerTest/spec2." + fileExt), spec2Path); + String mergedSpec = new MergedSpecBuilder( + Arrays.asList(spec1Path.toAbsolutePath().toString(), spec2Path.toAbsolutePath().toString()), + outputDir.getAbsolutePath(), "_merged" + ).buildMergedSpec(); // REF mode is default + ParseOptions opts = new ParseOptions(); opts.setResolve(true); + OpenAPI openAPI = new OpenAPIParser().readLocation(mergedSpec, null, opts).getOpenAPI(); + assertNotNull(openAPI.getPaths().get("/spec1"), "/spec1 must resolve via $ref"); + assertNotNull(openAPI.getPaths().get("/spec2"), "/spec2 must resolve via $ref"); + } + + @Test + public void shouldMergeExplicitFileListDeepMode_yaml() throws IOException { + shouldMergeExplicitFileListDeepMode("yaml"); + } + + @Test + public void shouldMergeExplicitFileListDeepMode_json() throws IOException { + shouldMergeExplicitFileListDeepMode("json"); + } + + /** DEEP mode with explicit file list: output is a self-contained spec with all schemas inlined. */ + private void shouldMergeExplicitFileListDeepMode(String fileExt) throws IOException { + File inputDir = Files.createTempDirectory("list-deep-in").toFile().getCanonicalFile(); + inputDir.deleteOnExit(); + File outputDir = Files.createTempDirectory("list-deep-out").toFile().getCanonicalFile(); + outputDir.deleteOnExit(); + java.nio.file.Path spec1Path = inputDir.toPath().resolve("spec1." + fileExt); + java.nio.file.Path spec2Path = inputDir.toPath().resolve("spec2." + fileExt); + Files.copy(Paths.get("src/test/resources/bugs/mergerTest/spec1." + fileExt), spec1Path); + Files.copy(Paths.get("src/test/resources/bugs/mergerTest/spec2." + fileExt), spec2Path); + String mergedSpec = new MergedSpecBuilder( + Arrays.asList(spec1Path.toAbsolutePath().toString(), spec2Path.toAbsolutePath().toString()), + outputDir.getAbsolutePath(), "_merged" + ).withMergeMode(MergedSpecBuilder.MergeMode.DEEP).buildMergedSpec(); + ParseOptions opts = new ParseOptions(); opts.setResolve(true); + OpenAPI openAPI = new OpenAPIParser().readLocation(mergedSpec, null, opts).getOpenAPI(); + assertNotNull(openAPI.getPaths().get("/spec1"), "/spec1 must be present"); + assertNotNull(openAPI.getPaths().get("/spec2"), "/spec2 must be present"); + assertNotNull(openAPI.getComponents().getSchemas().get("Spec1Model"), "Spec1Model must be inlined"); + assertNotNull(openAPI.getComponents().getSchemas().get("Spec2Model"), "Spec2Model must be inlined"); + } + + @Test + public void shouldExplicitFileListRespectsOrder_yaml() throws IOException { + shouldExplicitFileListRespectsOrder("yaml"); + } + + @Test + public void shouldExplicitFileListRespectsOrder_json() throws IOException { + shouldExplicitFileListRespectsOrder("json"); + } + + /** + * When a schema conflict exists, the first file in the explicit list wins. + * Listing spec-schema-conflict first keeps "differentField"; listing spec1 first keeps "spec1Field". + */ + private void shouldExplicitFileListRespectsOrder(String fileExt) throws IOException { + File inputDir = Files.createTempDirectory("list-order-in").toFile().getCanonicalFile(); + inputDir.deleteOnExit(); + File outputDir1 = Files.createTempDirectory("list-order-out1").toFile().getCanonicalFile(); + outputDir1.deleteOnExit(); + File outputDir2 = Files.createTempDirectory("list-order-out2").toFile().getCanonicalFile(); + outputDir2.deleteOnExit(); + java.nio.file.Path spec1Path = inputDir.toPath().resolve("spec1." + fileExt); + java.nio.file.Path conflictPath = inputDir.toPath().resolve("spec-schema-conflict." + fileExt); + Files.copy(Paths.get("src/test/resources/bugs/mergerTest/spec1." + fileExt), spec1Path); + Files.copy(Paths.get("src/test/resources/bugs/mergerTest/spec-schema-conflict." + fileExt), conflictPath); + ParseOptions opts = new ParseOptions(); opts.setResolve(true); + // conflict-first: differentField wins + String merged1 = new MergedSpecBuilder( + Arrays.asList(conflictPath.toAbsolutePath().toString(), spec1Path.toAbsolutePath().toString()), + outputDir1.getAbsolutePath(), "_merged" + ).withMergeMode(MergedSpecBuilder.MergeMode.DEEP).buildMergedSpec(); + OpenAPI api1 = new OpenAPIParser().readLocation(merged1, null, opts).getOpenAPI(); + assertNotNull(api1.getComponents().getSchemas().get("Spec1Model").getProperties().get("differentField"), + "differentField must win when spec-schema-conflict is listed first"); + // spec1-first: spec1Field wins + String merged2 = new MergedSpecBuilder( + Arrays.asList(spec1Path.toAbsolutePath().toString(), conflictPath.toAbsolutePath().toString()), + outputDir2.getAbsolutePath(), "_merged" + ).withMergeMode(MergedSpecBuilder.MergeMode.DEEP).buildMergedSpec(); + OpenAPI api2 = new OpenAPIParser().readLocation(merged2, null, opts).getOpenAPI(); + assertNotNull(api2.getComponents().getSchemas().get("Spec1Model").getProperties().get("spec1Field"), + "spec1Field must win when spec1 is listed first"); + } + + @Test + public void shouldFailOnEmptyExplicitFileList() { + try { + new MergedSpecBuilder(Collections.emptyList(), System.getProperty("java.io.tmpdir"), "_merged") + .buildMergedSpec(); + fail("Expected RuntimeException for empty file list"); + } catch (RuntimeException e) { + assertTrue(e.getMessage().toLowerCase(java.util.Locale.ROOT).contains("empty") || e.getMessage().toLowerCase(java.util.Locale.ROOT).contains("nothing"), + "Exception must indicate the list is empty"); + } + } +} \ No newline at end of file diff --git a/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec-collision.json b/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec-collision.json new file mode 100644 index 000000000000..a027821d8639 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec-collision.json @@ -0,0 +1,70 @@ +{ + "openapi": "3.0.3", + "info": { + "version": "1.0.0", + "title": "Collision Test Spec" + }, + "servers": [ + { "url": "api.my-domain.com/my-context-root/v1" } + ], + "paths": { + "/spec1": { + "post": { + "tags": ["spec1"], + "summary": "create spec1", + "operationId": "createSpec1", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Spec1Model" } + } + } + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Spec1Model" } + } + } + } + } + } + }, + "/collision": { + "get": { + "tags": ["collision"], + "summary": "collision endpoint", + "operationId": "collisionOperation", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CollisionModel" } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "CollisionModel": { + "type": "object", + "properties": { + "collisionField": { "type": "string" } + } + }, + "Spec1Model": { + "type": "object", + "properties": { + "spec1Field": { "type": "string" } + } + } + } + } +} diff --git a/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec-collision.yaml b/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec-collision.yaml new file mode 100644 index 000000000000..52c4fe2dab8f --- /dev/null +++ b/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec-collision.yaml @@ -0,0 +1,52 @@ +openapi: 3.0.3 +info: + version: 1.0.0 + title: Collision Test Spec +servers: + - url: api.my-domain.com/my-context-root/v1 +paths: + /spec1: + post: + tags: + - spec1 + summary: create spec1 + operationId: createSpec1 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Spec1Model' + responses: + '201': + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/Spec1Model' + /collision: + get: + tags: + - collision + summary: collision endpoint + operationId: collisionOperation + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/CollisionModel' + +components: + schemas: + CollisionModel: + type: object + properties: + collisionField: + type: string + Spec1Model: + type: object + properties: + spec1Field: + type: string diff --git a/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec-extensions.json b/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec-extensions.json new file mode 100644 index 000000000000..38fd32ff96e8 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec-extensions.json @@ -0,0 +1,42 @@ +{ + "openapi": "3.0.3", + "info": { + "version": "1.0.0", + "title": "Extensions Test Spec" + }, + "servers": [ + { "url": "api.my-domain.com/my-context-root/v1" } + ], + "paths": { + "/ext-path": { + "x-custom-path-ext": "path-level-value", + "get": { + "tags": ["ext"], + "summary": "extensions test", + "operationId": "extOperation", + "x-custom-op-ext": "operation-level-value", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ExtModel" } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "ExtModel": { + "type": "object", + "x-custom-schema-ext": "schema-level-value", + "properties": { + "extField": { "type": "string" } + } + } + } + } +} diff --git a/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec-extensions.yaml b/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec-extensions.yaml new file mode 100644 index 000000000000..85161c8cf770 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec-extensions.yaml @@ -0,0 +1,31 @@ +openapi: 3.0.3 +info: + version: 1.0.0 + title: Extensions Test Spec +servers: + - url: api.my-domain.com/my-context-root/v1 +paths: + /ext-path: + x-custom-path-ext: path-level-value + get: + tags: + - ext + summary: extensions test + operationId: extOperation + x-custom-op-ext: operation-level-value + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ExtModel' + +components: + schemas: + ExtModel: + type: object + x-custom-schema-ext: schema-level-value + properties: + extField: + type: string diff --git a/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec-noext.txt b/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec-noext.txt new file mode 100644 index 000000000000..b92555272056 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec-noext.txt @@ -0,0 +1 @@ +This is not a spec file. It should be silently ignored by MergedSpecBuilder. diff --git a/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec-pathlevel.json b/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec-pathlevel.json new file mode 100644 index 000000000000..f34d10c40c15 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec-pathlevel.json @@ -0,0 +1,48 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Path Level Test", + "version": "1.0.0" + }, + "servers": [ + { "url": "api.my-domain.com/my-context-root/v1" } + ], + "paths": { + "/spec1": { + "x-path-ext": "path-level-extension-from-incoming", + "parameters": [ + { + "in": "header", + "name": "X-Request-ID", + "schema": { "type": "string" }, + "required": false + } + ], + "patch": { + "tags": ["spec1"], + "summary": "patch spec1", + "operationId": "patchSpec1", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Spec1Model" } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Spec1Model": { + "type": "object", + "properties": { + "spec1Field": { "type": "string" } + } + } + } + } +} diff --git a/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec-pathlevel.yaml b/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec-pathlevel.yaml new file mode 100644 index 000000000000..1cfdaea13aff --- /dev/null +++ b/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec-pathlevel.yaml @@ -0,0 +1,34 @@ +openapi: 3.0.3 +info: + title: Path Level Test + version: 1.0.0 +servers: + - url: api.my-domain.com/my-context-root/v1 +paths: + /spec1: + x-path-ext: path-level-extension-from-incoming + parameters: + - in: header + name: X-Request-ID + schema: + type: string + required: false + patch: + tags: + - spec1 + summary: patch spec1 + operationId: patchSpec1 + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Spec1Model' +components: + schemas: + Spec1Model: + type: object + properties: + spec1Field: + type: string diff --git a/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec-schema-conflict.json b/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec-schema-conflict.json new file mode 100644 index 000000000000..c6472790c110 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec-schema-conflict.json @@ -0,0 +1,39 @@ +{ + "openapi": "3.0.3", + "info": { + "version": "1.0.0", + "title": "Schema Conflict Test Spec" + }, + "servers": [ + { "url": "api.my-domain.com/my-context-root/v1" } + ], + "paths": { + "/schema-conflict": { + "get": { + "tags": ["schemaConflict"], + "summary": "schema conflict test", + "operationId": "schemaConflictOperation", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Spec1Model" } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Spec1Model": { + "type": "object", + "properties": { + "differentField": { "type": "integer" } + } + } + } + } +} diff --git a/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec-schema-conflict.yaml b/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec-schema-conflict.yaml new file mode 100644 index 000000000000..54ed1b860ea8 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec-schema-conflict.yaml @@ -0,0 +1,28 @@ +openapi: 3.0.3 +info: + version: 1.0.0 + title: Schema Conflict Test Spec +servers: + - url: api.my-domain.com/my-context-root/v1 +paths: + /schema-conflict: + get: + tags: + - schemaConflict + summary: schema conflict test + operationId: schemaConflictOperation + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Spec1Model' + +components: + schemas: + Spec1Model: + type: object + properties: + differentField: + type: integer